diff --git a/README.md b/README.md
index 548509d..7e90913 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,33 @@ registration.abort();
For explicit installation, call `installWebMCP()` from `webmcp-polyfill`. Repeated calls are safe. Both entry points preserve existing native contexts, including partial implementations.
+### Declarative tools
+
+A form with `toolname` and `tooldescription` attributes is a tool. Its named controls make up the input schema, each described by its `toolparamdescription`, label, or `aria-description`. Disabled controls and controls with an applicable `readonly` attribute are left out. When multiple checkboxes or multiple radio buttons share a name, they form one parameter, described by the `toolparamdescription` of the nearest `fieldset` around them:
+
+```html
+
+```
+
+A call fills the form, then submits it if the form has `toolautosubmit`. Without that attribute, the form needs an enabled submit button: the call focuses it and waits for the form's next submission. A submission the page does not cancel resolves the call with `null`, as does `form.submit()`; one it cancels without responding rejects the call. To respond, call `preventDefault()` and then `respondWith()` in the `submit` listener:
+
+```js
+const form = document.querySelector('form[toolname="search-flights"]');
+form.addEventListener("submit", (event) => {
+ if (event.agentInvoked) {
+ event.preventDefault();
+ event.respondWith(searchFlights(new FormData(form)));
+ }
+});
+```
+
+The call resolves with the response: objects are JSON-serialized, and other values are converted to strings. A rejected response or a form reset rejects the call. So does removing the form or changing its tool attributes, unless the page does that during the agent's `submit` event.
+
### Script tag
Serve the built `dist/polyfill.js` before your app:
@@ -89,22 +116,18 @@ For cross-origin tools, delegate the `tools` permission on the iframe, register
```
-Initial discovery waits up to 500 ms for existing frames. Requests use `MessageChannel` after checking the peer's source and origin; callbacks run in their owning frame. Cancellation preserves the caller's reason and sends the callback a default `AbortError`.
+Initial discovery waits up to 500 ms for existing frames. Callbacks run in their owning frame. Cancellation preserves the caller's reason and sends the callback a default `AbortError`.
## Implementation status
-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.
+The target is `webmcp-types`: registration, discovery, execution, cancellation, `toolchange`, and the `toolactivated`/`toolcancel` lifecycle events, including frame exposure and origin filtering. The polyfill also implements declarative tools, which follow the [declarative API explainer](https://github.com/webmachinelearning/webmcp/blob/main/declarative-api-explainer.md) and Chromium because the draft leaves them unwritten. The `:tool-form-active` and `:tool-submit-active` pseudo-classes 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.
-`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.
+`executeTool()` accepts an object and returns a JSON-serialized result, or a declarative tool's response, which is `null` if its form navigates. Omitted or `undefined` input defaults to a fresh empty object. Callbacks must validate their inputs; schema inference provides TypeScript checks only.
Breaking API changes ship with notes: in minor releases while the version is 0.x, in majors after 1.0.
-## Development
-
-`src/` holds the polyfill, `tests/` the browser and package checks, and `wpt/` the upstream runner, pin, and expectations.
-
## License
[MIT](LICENSE).
diff --git a/TESTING.md b/TESTING.md
index 8ea8b4f..5c28d75 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -2,7 +2,7 @@
## Results
-**238 browser tests pass** across Chromium 153.0.8010.12, Firefox 155.0, and
+**352 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.
@@ -12,19 +12,20 @@ in Chrome and Firefox:
| Subtest result | Count |
| --- | ---: |
-| PASS | 116 |
-| Expected FAIL | 33 |
-| Expected TIMEOUT | 25 |
-| Expected NOTRUN | 16 |
+| PASS | 147 |
+| Expected FAIL | 37 |
+| Expected TIMEOUT | 5 |
+| Expected NOTRUN | 1 |
-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: 44 OK, 25 expected timeouts, one expected error.
+Tested locally with Chrome Canary 157.0.8084.0 and Firefox Nightly 158.0a1.
+At the file level: 63 OK, five expected timeouts, two expected errors.
+Safari may report the detached-frame test before its unhandled rejection reaches
+the harness, so that file allows either OK or ERROR; its subtest must still FAIL.
Expected failures are still failures. `NOTRUN` means an earlier timeout prevented
-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.
+the test from running, including one abort case. All 38 pinned IDL checks pass;
+the pinned IDL does not include the declarative `SubmitEvent` members. This is
+not full conformance.
## Run locally
@@ -41,6 +42,12 @@ pnpm test:package
native WebMCP. A separate Chromium test uses `--enable-features=WebMCP` to check
that loading the polyfill preserves the native context and its tools.
+Use WPT for shared conformance cases. Local tests cover installation, documented
+polyfill differences, and assertions that WPT does not make or reach. Compare
+individual assertions and recorded results before adding coverage: the pinned
+manual-submit, reset, and abort tests stop at unsupported pseudo-class checks,
+so local tests still exercise the behavior beyond those failures.
+
WPT needs Python 3.11+ and a clean checkout at
[`fe52996`](https://github.com/web-platform-tests/wpt/commit/fe52996d4465f23617bce91927bdd58e6ce8f541).
The [CI workflow](.github/workflows/test.yml) has the sparse-checkout and dependency setup.
@@ -53,6 +60,11 @@ WPT_ROOT=../wpt WPT_BROWSER=safari pnpm test:wpt
Firefox downloads Nightly unless `FIREFOX_BIN` is set.
Safari requires macOS, [Remote Automation, and WPT hosts-file setup](https://web-platform-tests.org/running-tests/safari.html).
+Safari 26.6.2 opens a native confirmation sheet when the HTTPS tests submit to
+`about:blank`. It prevents WebDriver from closing four test tabs, so WPT discards
+five subtest results and fails the completeness check at 185/190. The current
+warning does not honor `AskBeforeSubmittingInsecureForms`. Using an HTTPS form
+destination avoids the warning; that fixture repair needs to land in WPT.
Set `WPT_PYTHON` or `WPT_VENV` to use an existing Python environment. Extra arguments
go to WPT. On macOS, Firefox may need `--certutil-binary` pointing to a wrapper that
@@ -68,13 +80,66 @@ other non-testharness files are outside this suite.
Checked against [draft `d61d0e6`](https://github.com/webmachinelearning/webmcp/blob/d61d0e6d297ddb6bff3510b1330dbb215c6ef43c/index.bs)
and `webmcp-types@0.1.10`.
-- **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.
+- **Missing APIs:** scripts cannot add selectors, so the `:tool-form-active` and
+ `:tool-submit-active` pseudo-classes are unsupported.
+- **Pinned WPT differences:** callback results are JSON-serialized as the draft
+ requires; some pinned tests expect raw strings.
- **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.
+- **Declarative tools:** the draft's declarative section is a TODO, so they follow the
+ [explainer](https://github.com/webmachinelearning/webmcp/blob/d61d0e6d297ddb6bff3510b1330dbb215c6ef43c/declarative-api-explainer.md)
+ and Chromium at [`dbdbb13`](https://chromium.googlesource.com/chromium/src/+/dbdbb13fd74c9411ca2e39ba087fa2e031184ff5/third_party/blink/renderer/core/html/forms/).
+ Schemas match the cases in Chromium's `html_form_mcp_tool_test.cc` at that revision,
+ except those behind its file-input and custom-element flags; those controls are
+ unsupported. As in Chromium and the pinned tests, a submission that navigates
+ resolves `executeTool()` with `null`, although the draft and `webmcp-types` declare a
+ string. Installation adds `agentInvoked` and `respondWith()` to
+ `SubmitEvent.prototype` and wraps `HTMLFormElement.prototype.submit()`. Differences
+ from Chromium:
+ - `toolactivated` fires once the form is filled, before it submits or waits for
+ the user, as the explainer describes; Chromium fires it afterwards, even when
+ filling fails.
+ - From the agent's submit event until the polyfill settles the submission in a
+ later task, a removal or tool attribute change keeps the call, and
+ `respondWith()` is accepted; Chromium allows both only during the event's
+ dispatch, which includes microtasks that listeners queue.
+ - With `toolautosubmit`, the polyfill submits from script, so those microtasks run
+ after the dispatch, and `preventDefault()` after an `await` no longer stops the
+ submission; Chromium submits natively and honors it.
+ - A change to the form's controls replaces the tool without cancelling a call that
+ waits for the user; Chromium cancels it.
+ - Moving a form that waits for the user, which mutation observers see as no
+ change, keeps its call; Chromium cancels it.
+ - A reset cancels a call only if it reaches the polyfill's window listener
+ uncanceled. Chromium also cancels the call when a listener stops the reset's
+ propagation, and keeps it when a later window listener cancels the reset.
+ - A newer call rejects an older one that waits for the user, while one that
+ waits for the page's response still settles; Chromium leaves the older call
+ pending.
+ - Only a call's first submission is the agent's; Chromium also counts a later
+ submission while the page's response is pending.
+ - A window capture listener that the page added before installation can stop the
+ agent's `submit` event before the polyfill sees it, unless the listener reads
+ `agentInvoked` or calls `respondWith()` first.
+ - When a name frees up, the first form in document order that claims it
+ registers; Chromium registers a form whose name was taken only when that form
+ changes.
+ - A submission that fails validation keeps a call that waits for the user;
+ Chromium rejects it.
+ - Numbers fill controls as `String()` converts them; Chromium formats those that
+ are not 32-bit integers with six significant digits, and rejects them for
+ checkboxes.
+ - Numeric schema values use JavaScript numbers. Step-base divisibility uses the
+ raw decimal attributes, up to 18 coefficient digits and exponents from -1023
+ to 1023. Beyond those bounds, `multipleOf` is omitted instead of reproducing
+ Blink's Decimal rounding. Its conversion to a schema number can also round
+ differently from JavaScript.
+ - The fill's `input` and `change` events are untrusted.
+ - `SubmitEventInit` has no `agentInvoked` member, as in the explainer.
+ - Forms in shadow trees are unsupported; Chromium registers them, including in
+ closed shadow roots.
- **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
@@ -92,8 +157,10 @@ and `webmcp-types@0.1.10`.
excludes it.
- **Policy and origins:** without native policy introspection, only accessible
iframe delegation can be checked, not HTTP Permissions Policy. Cross-origin
- ancestors must also load the polyfill. Navigation inheritance is approximate;
- browser-specific trusted schemes and opaque execution origins are unsupported.
+ ancestors must also load the polyfill; if they load it after a frame's startup
+ wait, that frame's forms register at its next API call. Navigation inheritance is
+ approximate; browser-specific trusted schemes and opaque execution origins are
+ unsupported.
When updating the pins, compare the [draft](https://webmachinelearning.github.io/webmcp/),
[WPT](https://github.com/web-platform-tests/wpt/tree/master/webmcp), and
diff --git a/src/declarative.ts b/src/declarative.ts
new file mode 100644
index 0000000..57c7298
--- /dev/null
+++ b/src/declarative.ts
@@ -0,0 +1,1093 @@
+import {
+ NativeDOMException,
+ canDefine,
+ executionError,
+ isObject,
+ queueTask,
+ toolNamePattern,
+ type StoredTool,
+ type ToolMetadata,
+} from "./tools.js";
+
+/** The tool map that declarative tools share with registerTool(), and its change notification. */
+interface ToolHost {
+ tools: Map;
+ changed(): void;
+}
+
+interface FormDefinition extends Pick {
+ autosubmit: boolean;
+ serializedSchema: string;
+ declaration: string;
+}
+
+interface PendingSubmission {
+ form: HTMLFormElement;
+ /** "submitting" lasts until the settlement task; the page's response may remain pending. */
+ phase: "waiting" | "submitting" | "handled";
+ response?: Promise;
+ resolve(result: unknown): void;
+ reject(): void;
+}
+
+// Navigation resolves null; a null response serializes as "null".
+const navigated = Symbol("navigated");
+const submissions = new WeakMap();
+const documentTools = new WeakMap();
+
+/**
+ * Adds the explainer's SubmitEvent members, and lets form.submit() complete the form's tool calls.
+ *
+ * @throws {TypeError} If a prototype prevents installation; nothing is defined then.
+ * @see https://github.com/webmachinelearning/webmcp/blob/main/declarative-api-explainer.md#events
+ * @see https://webidl.spec.whatwg.org/#es-promise
+ */
+export function installDeclarative(): void {
+ const patches: [object, string][] = [
+ [SubmitEvent.prototype, "agentInvoked"],
+ [SubmitEvent.prototype, "respondWith"],
+ [HTMLFormElement.prototype, "submit"],
+ ];
+ if (!patches.every(([target, name]) => canDefine(target, name))) {
+ throw new TypeError("Cannot install WebMCP on this realm");
+ }
+
+ const getSubmitter = Object.getOwnPropertyDescriptor(SubmitEvent.prototype, "submitter")!.get!;
+ // A method is non-constructible; the submitter getter supplies the native brand check.
+ const { agentInvoked, respondWith } = {
+ agentInvoked(this: SubmitEvent): boolean {
+ getSubmitter.call(this);
+ return submissionOf(this) !== undefined;
+ },
+ respondWith(this: SubmitEvent, agentResponse: unknown): void {
+ getSubmitter.call(this);
+ if (arguments.length < 1) {
+ throw new TypeError("respondWith() requires a response");
+ }
+ // Web IDL adopts the response into a fresh promise before the method's steps.
+ const response = new Promise((resolve) => resolve(agentResponse));
+ const submission = submissionOf(this);
+ // Chromium's checks, in its order: agent-invoked, canceled, still dispatching. The polyfill
+ // settles a submission in a later task, so a listener may respond after awaiting.
+ if (!submission) {
+ throw new NativeDOMException(
+ "Only a submission caused by an agent can respond to it",
+ "InvalidStateError",
+ );
+ }
+ if (!this.defaultPrevented) {
+ throw new NativeDOMException(
+ "Call preventDefault() before respondWith()",
+ "InvalidStateError",
+ );
+ }
+ if (submission.phase !== "submitting") {
+ throw new NativeDOMException(
+ "The submission has already been handled",
+ "InvalidStateError",
+ );
+ }
+ // Prevent unhandledrejection before the settlement task observes the response.
+ void response.catch(() => {});
+ submission.response = response;
+ },
+ };
+ Object.defineProperty(agentInvoked, "name", { value: "get agentInvoked" });
+ Object.defineProperties(SubmitEvent.prototype, {
+ agentInvoked: { get: agentInvoked, enumerable: true, configurable: true },
+ respondWith: { value: respondWith, writable: true, enumerable: true, configurable: true },
+ });
+
+ const nativeSubmit = HTMLFormElement.prototype.submit;
+ const { submit } = {
+ submit(this: HTMLFormElement): void {
+ nativeSubmit.call(this);
+ // submit() skips the submit event, so complete the form's running calls here.
+ documentTools.get(formMember(this, "ownerDocument"))?.submitted(this);
+ },
+ };
+ Object.defineProperty(HTMLFormElement.prototype, "submit", {
+ value: submit,
+ writable: true,
+ enumerable: true,
+ configurable: true,
+ });
+ // Capturing at installation marks an agent's submission before any listener the page adds later.
+ addEventListener("submit", (event) => documentTools.get(document)?.submitting(event), true);
+}
+
+/** Recognizes submissions even in capture listeners registered before the polyfill. */
+function submissionOf(event: SubmitEvent): PendingSubmission | undefined {
+ documentTools.get(document)?.submitting(event);
+ return submissions.get(event);
+}
+
+/**
+ * Maintains tool registrations and pending calls for forms that declare tools.
+ *
+ * @see https://github.com/webmachinelearning/webmcp/blob/main/declarative-api-explainer.md#processing-model
+ * @see [Tracked sources and limitations](../TESTING.md#draft-alignment-and-limitations)
+ */
+export class DeclarativeTools {
+ readonly #document: Document;
+ readonly #host: ToolHost;
+ readonly #registrations = new Map();
+ /** Responses may overlap, but only one call per form waits for submission. */
+ readonly #pending = new Set();
+
+ constructor(view: Window, host: ToolHost) {
+ this.#document = view.document;
+ this.#host = host;
+ documentTools.set(this.#document, this);
+ // A reset counts only once the form's listeners could prevent it, so it is heard as it bubbles.
+ view.addEventListener("reset", (event) => this.#resetting(event));
+ // Labels and associated controls outside a form can change its schema.
+ new MutationObserver(() => this.update()).observe(this.#document, {
+ subtree: true,
+ childList: true,
+ characterData: true,
+ attributeFilter: definingAttributes,
+ });
+ this.update();
+ }
+
+ /**
+ * Frees names of removed or incomplete declarations before imperative registration.
+ * Leaves other changes for update(), matching Chromium's deferred registration.
+ */
+ release(): void {
+ if (this.#refreshRegistrations(true)) {
+ this.#host.changed();
+ }
+ }
+
+ update(): void {
+ let changed = this.#refreshRegistrations();
+ // The first form to claim a free name holds it, in document order.
+ for (const form of this.#document.forms) {
+ if (this.#registrations.has(form)) {
+ continue;
+ }
+ const definition = readDefinition(form);
+ // Chromium checks the name only when it registers the form, so release() keeps a form
+ // renamed to an invalid name until then.
+ if (
+ !definition ||
+ !toolNamePattern.test(definition.name) ||
+ this.#host.tools.has(definition.name)
+ ) {
+ continue;
+ }
+ this.#host.tools.set(definition.name, this.#createTool(form, definition));
+ this.#registrations.set(form, definition);
+ changed = true;
+ }
+ if (changed) {
+ this.#host.changed();
+ }
+ }
+
+ submitted(form: HTMLFormElement): void {
+ for (const pending of this.#callsOf(form)) {
+ pending.resolve(navigated);
+ }
+ }
+
+ /** A waiting tool call owns the form's next trusted submission, including a user's. */
+ submitting(event: Event): void {
+ if (!event.isTrusted || event.eventPhase === Event.NONE || submissions.has(event)) {
+ return;
+ }
+ const pending = this.#waiting(event.target);
+ // A shadow tree's submission never reaches the window listener, so it is not the agent's.
+ if (!pending || !this.#document.contains(pending.form)) {
+ return;
+ }
+ submissions.set(event, pending);
+ pending.phase = "submitting";
+ // The page's listeners, and the microtasks they queue, run before this task.
+ queueTask(() => this.#settle(pending, event));
+ }
+
+ #refreshRegistrations(removeOnly = false): boolean {
+ let changed = false;
+ for (const [form, registered] of this.#registrations) {
+ // A form moved to another document or into a shadow tree is out of the observer's reach.
+ const definition = this.#document.contains(form) ? readDefinition(form) : undefined;
+ const sameDeclaration = definition?.declaration === registered.declaration;
+ const unchanged =
+ sameDeclaration && definition?.serializedSchema === registered.serializedSchema;
+ if (definition && (removeOnly || unchanged)) {
+ continue;
+ }
+ // Keep the name claimed while replacing the form's tool.
+ if (definition?.name === registered.name) {
+ this.#host.tools.set(definition.name, this.#createTool(form, definition));
+ this.#registrations.set(form, definition);
+ } else {
+ this.#host.tools.delete(registered.name);
+ this.#registrations.delete(form);
+ }
+ changed = true;
+ // Schema changes replace the tool without canceling its calls.
+ if (sameDeclaration) {
+ continue;
+ }
+ for (const pending of this.#callsOf(form)) {
+ // Observers see submit-handler mutations later, so preserve calls until settlement.
+ if (pending.phase === "submitting") {
+ continue;
+ }
+ pending.reject();
+ }
+ }
+ return changed;
+ }
+
+ #createTool(form: HTMLFormElement, definition: FormDefinition): StoredTool {
+ const { name, title, description, autosubmit, serializedSchema } = definition;
+ return {
+ metadata: { name, title, description, annotations: undefined, serializedSchema },
+ exposedTo: [],
+ run: (input, signal, activate) => this.#execute(form, autosubmit, input, signal, activate),
+ serialize: (result) => (result === navigated ? null : serializeResponse(result)),
+ };
+ }
+
+ #execute(
+ form: HTMLFormElement,
+ autosubmit: boolean,
+ input: object,
+ signal: AbortSignal,
+ activate: () => void,
+ ): Promise {
+ if (Array.isArray(input) || (!autosubmit && !defaultButton(this.#document, form))) {
+ throw executionError();
+ }
+ fillForm(form, input);
+
+ return new Promise((resolve, reject) => {
+ const pending: PendingSubmission = {
+ form,
+ phase: "waiting",
+ resolve: (result) => {
+ this.#pending.delete(pending);
+ resolve(result);
+ },
+ reject: () => {
+ this.#pending.delete(pending);
+ reject(executionError());
+ },
+ };
+ // A newer call rejects one that still waits for the user; Chromium leaves it pending.
+ this.#waiting(form)?.reject();
+ this.#pending.add(pending);
+ signal.addEventListener("abort", () => pending.reject(), { once: true });
+
+ // The explainer puts toolactivated between filling and submitting; Chromium fires it after.
+ activate();
+ // A toolactivated listener may have settled the call or submitted the form itself.
+ if (!this.#pending.has(pending) || pending.phase !== "waiting") {
+ return;
+ }
+ // Filling and toolactivated listeners can replace or disable the submit button.
+ const submitter = defaultButton(this.#document, form);
+ if (!autosubmit) {
+ submitter?.focus();
+ return;
+ }
+ formMember(form, "requestSubmit").call(form, submitter ?? null);
+ // A form that fails validation, or cannot submit for another reason, fires no submit event.
+ if (pending.phase === "waiting") {
+ pending.reject();
+ }
+ });
+ }
+
+ #settle(pending: PendingSubmission, event: Event): void {
+ // Close respondWith() even if reset or script submission already settled the call.
+ pending.phase = "handled";
+ if (!this.#pending.has(pending)) {
+ return;
+ }
+ if (pending.response) {
+ pending.response.then(pending.resolve, pending.reject);
+ return;
+ }
+ if (event.defaultPrevented) {
+ pending.reject();
+ return;
+ }
+ pending.resolve(navigated);
+ }
+
+ #resetting(event: Event): void {
+ if (!event.isTrusted || event.defaultPrevented) {
+ return;
+ }
+ for (const pending of this.#callsOf(event.target)) {
+ pending.reject();
+ }
+ }
+
+ #callsOf(target: EventTarget | null): PendingSubmission[] {
+ return [...this.#pending].filter((pending) => pending.form === target);
+ }
+
+ #waiting(target: EventTarget | null): PendingSubmission | undefined {
+ return this.#callsOf(target).find((pending) => pending.phase === "waiting");
+ }
+}
+
+type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
+
+type Parameter =
+ | { kind: "checkbox" | "radio"; controls: [HTMLInputElement, ...HTMLInputElement[]] }
+ | { kind: "select"; control: HTMLSelectElement }
+ | { kind: "text"; control: HTMLInputElement | HTMLTextAreaElement }
+ | {
+ kind: "date" | "datetime-local" | "month" | "week" | "time" | "number" | "range" | "color";
+ control: HTMLInputElement;
+ };
+
+const definingAttributes = [
+ "toolname",
+ "tooldescription",
+ "tooltitle",
+ "toolautosubmit",
+ "toolparamdescription",
+ "aria-description",
+ "name",
+ "type",
+ "required",
+ "disabled",
+ "readonly",
+ "multiple",
+ "pattern",
+ "min",
+ "max",
+ "step",
+ "value",
+ "form",
+ "id",
+ "for",
+];
+
+function readDefinition(form: HTMLFormElement): FormDefinition | undefined {
+ const attribute = (name: string) => formMember(form, "getAttribute").call(form, name);
+ const name = attribute("toolname");
+ const description = attribute("tooldescription");
+ // Chromium requires both attributes to be present; an empty description is allowed.
+ if (name === null || description === null) {
+ return undefined;
+ }
+ const title = attribute("tooltitle") ?? "";
+ const autosubmit = attribute("toolautosubmit") !== null;
+ return {
+ name,
+ title,
+ description,
+ autosubmit,
+ serializedSchema: JSON.stringify(inputSchema(form)),
+ declaration: JSON.stringify([name, title, description, autosubmit]),
+ };
+}
+
+function inputSchema(form: HTMLFormElement): object {
+ const properties = new Map();
+ const required: string[] = [];
+ for (const [name, controls] of controlsByName(form)) {
+ const parameter = readParameter(controls);
+ if (!parameter) {
+ continue;
+ }
+ properties.set(name, parameterSchema(parameter));
+ if (controls.some((control) => control.hasAttribute("required"))) {
+ required.push(name);
+ }
+ }
+ // fromEntries keeps a parameter named "__proto__" an own property.
+ return { type: "object", properties: Object.fromEntries(properties), required };
+}
+
+function controlsByName(form: HTMLFormElement): Map {
+ const groups = new Map();
+ for (const element of formMember(form, "elements")) {
+ const name = parameterName(element);
+ if (name === undefined) {
+ continue;
+ }
+ const controls = groups.get(name);
+ if (controls) {
+ controls.push(element);
+ } else {
+ groups.set(name, [element]);
+ }
+ }
+ return groups;
+}
+
+function parameterName(element: Element): string | undefined {
+ const name = element.getAttribute("name") ?? "";
+ if (isFormAssociatedCustom(element)) {
+ // Form-associated custom elements join by their untrimmed name, even when disabled.
+ return name || undefined;
+ }
+ if (isHTML(element, "object") || isDisabledOrReadOnly(element)) {
+ return undefined;
+ }
+ return stripWhitespace(name);
+}
+
+/**
+ * Preserves the names and labels that Blink's StripWhiteSpace treats as distinct. String.trim()
+ * would also strip non-breaking spaces, paragraph separators and BOMs.
+ */
+function stripWhitespace(value: string): string {
+ return value.replace(
+ /^[\t-\r \u1680\u2000-\u200a\u2028\u205f\u3000]+|[\t-\r \u1680\u2000-\u200a\u2028\u205f\u3000]+$/gu,
+ "",
+ );
+}
+
+const readOnlyTypes = new Set([
+ "text",
+ "search",
+ "url",
+ "tel",
+ "email",
+ "password",
+ "date",
+ "month",
+ "week",
+ "time",
+ "datetime-local",
+ "number",
+]);
+
+function isDisabledOrReadOnly(element: Element): boolean {
+ if (element.matches(":disabled")) {
+ return true;
+ }
+ if (!element.hasAttribute("readonly")) {
+ return false;
+ }
+ return (
+ isHTML(element, "textarea") || (isHTML(element, "input") && readOnlyTypes.has(element.type))
+ );
+}
+
+function readParameter(controls: Element[]): Parameter | undefined {
+ const kinds = new Set(controls.map(controlKind));
+ const [kind] = kinds;
+ if (kinds.size !== 1 || !kind) {
+ return undefined;
+ }
+ const isGroup = kind === "checkbox" || kind === "radio";
+ if (!isGroup && controls.length !== 1) {
+ return undefined;
+ }
+ // SAFETY: the group is nonempty, and controlKind() establishes each kind's element types.
+ return (isGroup ? { kind, controls } : { kind, control: controls[0] }) as Parameter;
+}
+
+function controlKind(element: Element): Parameter["kind"] | undefined {
+ if (isHTML(element, "textarea")) {
+ return "text";
+ }
+ if (isHTML(element, "select")) {
+ return "select";
+ }
+ if (!isHTML(element, "input")) {
+ return undefined;
+ }
+ const type = inputType(element);
+ switch (type) {
+ case "text":
+ case "email":
+ case "search":
+ case "tel":
+ case "url":
+ case "password":
+ return "text";
+ case "hidden":
+ // A hidden input is a parameter only when the page describes it.
+ return element.getAttribute("toolparamdescription") ? "text" : undefined;
+ case "date":
+ case "datetime-local":
+ case "month":
+ case "week":
+ case "time":
+ case "number":
+ case "range":
+ case "checkbox":
+ case "radio":
+ case "color":
+ return type;
+ default:
+ return undefined;
+ }
+}
+
+/** Preserves authored month/week types when the browser reflects unsupported types as text. */
+function inputType(input: HTMLInputElement): string {
+ const type = input.getAttribute("type")?.toLowerCase();
+ return type === "month" || type === "week" ? type : input.type;
+}
+
+function parameterSchema(parameter: Parameter): object {
+ const schema = valueSchema(parameter);
+ let description = parameterDescription(parameter);
+ if (parameter.kind === "date") {
+ const note = "Dates MUST be provided in 'YYYY-MM-DD' format.";
+ description = description ? `${description} (${note})` : note;
+ }
+ return description ? { ...schema, description } : schema;
+}
+
+const timePattern = "([01][0-9]|2[0-3]):[0-5][0-9]";
+
+function valueSchema(parameter: Parameter): object {
+ switch (parameter.kind) {
+ case "text":
+ return { type: "string", ...patternOf(parameter.control) };
+ case "date":
+ return { type: "string", format: "date" };
+ case "datetime-local": {
+ const seconds = secondsPattern(parameter.control);
+ return {
+ type: "string",
+ format: `^[0-9]{4}-(0[1-9]|1[0-2])-[0-9]{2}T${timePattern}${seconds}$`,
+ };
+ }
+ case "month":
+ return { type: "string", format: "^[0-9]{4}-(0[1-9]|1[0-2])$" };
+ case "week":
+ return { type: "string", format: "^[0-9]{4}-W(0[1-9]|[1-4][0-9]|5[0-3])$" };
+ case "time":
+ return { type: "string", format: `^${timePattern}${secondsPattern(parameter.control)}$` };
+ case "number":
+ return {
+ type: "number",
+ ...numberLimits(parameter.control),
+ ...multipleOf(parameter.control),
+ ...patternOf(parameter.control),
+ };
+ case "range":
+ return {
+ type: "number",
+ ...rangeLimits(parameter.control),
+ ...multipleOf(parameter.control),
+ };
+ case "color":
+ // Chromium's pattern accepts any letter, although only hexadecimal digits are colors.
+ return { type: "string", format: "^#[0-9a-zA-Z]{6}$" };
+ case "checkbox":
+ if (parameter.controls.length === 1) {
+ return { type: "boolean" };
+ }
+ return arrayOf(choices(parameter.controls));
+ case "radio":
+ return choices(parameter.controls);
+ case "select": {
+ const { control } = parameter;
+ const options = Array.from(control.options, (option) => ({
+ const: option.value,
+ title: option.textContent,
+ }));
+ return control.multiple ? arrayOf(enumOf(options)) : enumOf(options);
+ }
+ }
+}
+
+function choices(inputs: HTMLInputElement[]): object {
+ return enumOf(
+ inputs.map((input) => {
+ const title = labelText(input);
+ return title ? { const: input.value, title } : { const: input.value };
+ }),
+ );
+}
+
+function enumOf(options: { const: string; title?: string | null }[]): object {
+ return {
+ type: "string",
+ anyOf: options.map((option) => ({ type: "string", ...option })),
+ enum: options.map((option) => option.const),
+ };
+}
+
+function arrayOf(items: object): object {
+ return { type: "array", items, uniqueItems: true };
+}
+
+/**
+ * HTML patterns use Unicode sets syntax, so validation requires the v flag.
+ * @see https://html.spec.whatwg.org/multipage/input.html#attr-input-pattern
+ */
+function patternOf(control: Element): { pattern?: string } {
+ const pattern = isHTML(control, "input") ? control.getAttribute("pattern") : null;
+ if (pattern === null) {
+ return {};
+ }
+ try {
+ new RegExp(pattern, "v");
+ } catch {
+ return {};
+ }
+ return { pattern };
+}
+
+function numberLimits(control: Element): object {
+ const minimum = parseNumber(control.getAttribute("min"));
+ const maximum = parseNumber(control.getAttribute("max"));
+ return {
+ ...(minimum !== undefined && { minimum }),
+ ...(maximum !== undefined && { maximum }),
+ };
+}
+
+/** @see https://html.spec.whatwg.org/multipage/input.html#range-state-(type=range) */
+function rangeLimits(control: Element): object {
+ const minimum = parseNumber(control.getAttribute("min")) ?? 0;
+ const maximum = Math.max(parseNumber(control.getAttribute("max")) ?? 100, minimum);
+ return { minimum, maximum };
+}
+
+/** multipleOf cannot express HTML's step offset unless the base is also a multiple of the step. */
+function multipleOf(control: HTMLInputElement): { multipleOf?: number } {
+ const isRange = control.type === "range";
+ const step = parseStep(control, 1) ?? (isRange ? 1 : undefined);
+ if (step === undefined) {
+ return {};
+ }
+ const stepAttribute = control.getAttribute("step");
+ const stepText =
+ stepAttribute !== null && parseNumber(stepAttribute) === step ? stepAttribute : String(step);
+ return isMultiple(stepBase(control), stepText) ? { multipleOf: step } : {};
+}
+
+/** Suggests the time precision accepted by the control's step, matching Chromium's schema. */
+function secondsPattern(control: Element): string {
+ const step = Math.max(Math.round((parseStep(control, 60) ?? 60) * 1000), 1);
+ if (step < 1000) {
+ return "(:[0-5][0-9](\\.[0-9]{1,3})?)?";
+ }
+ return step < 60000 ? "(:[0-5][0-9])?" : "";
+}
+
+/**
+ * @returns undefined for step="any".
+ * @see https://html.spec.whatwg.org/multipage/input.html#concept-input-step
+ */
+function parseStep(control: Element, defaultStep: number): number | undefined {
+ const value = control.getAttribute("step");
+ if (value?.toLowerCase() === "any") {
+ return undefined;
+ }
+ const step = parseNumber(value);
+ return step !== undefined && step > 0 ? step : defaultStep;
+}
+
+/**
+ * Keeps raw attribute text for exact decimal divisibility checks.
+ * @see https://html.spec.whatwg.org/multipage/input.html#concept-input-min-zero
+ */
+function stepBase(control: Element): string {
+ return (
+ [control.getAttribute("min"), control.getAttribute("value")].find(
+ (value) => parseNumber(value) !== undefined,
+ ) ?? "0"
+ );
+}
+
+/** Checks divisibility before Number conversion can round away a decimal remainder. */
+function isMultiple(value: string, step: string): boolean {
+ const base = decimalParts(value);
+ const increment = decimalParts(step);
+ if (!base || !increment) {
+ return false;
+ }
+ const commonExponent = Math.min(base.exponent, increment.exponent);
+ const dividend = base.coefficient * 10n ** BigInt(base.exponent - commonExponent);
+ const divisor = increment.coefficient * 10n ** BigInt(increment.exponent - commonExponent);
+ return dividend % divisor === 0n;
+}
+
+function decimalParts(value: string): { coefficient: bigint; exponent: number } | undefined {
+ const [significand = "", exponentText = "0"] = value.toLowerCase().split("e");
+ const fractionLength = significand.split(".")[1]?.length ?? 0;
+ const coefficientText = significand.replace(".", "").replace(/^(-?)0+(?=\d)/u, "$1");
+ const exponent = Number(exponentText) - fractionLength;
+ // Omit constraints that would require Blink's Decimal rounding beyond these bounds.
+ if (coefficientText.replace("-", "").length > 18 || Math.abs(exponent) > 1023) {
+ return undefined;
+ }
+ return { coefficient: BigInt(coefficientText), exponent };
+}
+
+/**
+ * HTML numeric syntax excludes some strings that Number() accepts.
+ * @see https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-floating-point-number
+ */
+function parseNumber(value: string | null): number | undefined {
+ if (value === null || !/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?$/u.test(value)) {
+ return undefined;
+ }
+ const number = Number(value);
+ return Number.isFinite(number) ? number : undefined;
+}
+
+function parameterDescription(parameter: Parameter): string | undefined {
+ if ("controls" in parameter && parameter.controls.length > 1) {
+ // A group is described only by the nearest fieldset around all of its controls.
+ return commonFieldset(parameter.controls)?.getAttribute("toolparamdescription") || undefined;
+ }
+ const control = "controls" in parameter ? parameter.controls[0] : parameter.control;
+ return (
+ control.getAttribute("toolparamdescription") ||
+ labelText(control) ||
+ control.getAttribute("aria-description") ||
+ undefined
+ );
+}
+
+function commonFieldset(
+ controls: [FormControl, ...FormControl[]],
+): HTMLFieldSetElement | undefined {
+ const [first] = controls;
+ let ancestor: Element | null = first;
+ // Either climb can reach a form whose named controls shadow parentElement or contains.
+ for (const control of controls) {
+ while (ancestor && !Node.prototype.contains.call(ancestor, control)) {
+ // SAFETY: parentElement is an Element or null.
+ ancestor = Reflect.get(Node.prototype, "parentElement", ancestor) as Element | null;
+ }
+ }
+ const form = first.form;
+ while (ancestor && ancestor !== form) {
+ if (isHTML(ancestor, "fieldset")) {
+ return ancestor;
+ }
+ ancestor = Reflect.get(Node.prototype, "parentElement", ancestor) as Element | null;
+ }
+ return undefined;
+}
+
+// Text inside a nested control, such as a select's options, describes that control instead.
+function labelText(control: FormControl): string {
+ return Array.from(control.labels ?? [], (label) => {
+ let text = "";
+ const walker = label.ownerDocument.createTreeWalker(
+ label,
+ NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
+ (node) =>
+ // SAFETY: a node of type ELEMENT_NODE is an Element in any window.
+ node.nodeType === Node.ELEMENT_NODE && isLabelable(node as Element)
+ ? NodeFilter.FILTER_REJECT
+ : NodeFilter.FILTER_ACCEPT,
+ );
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ // SAFETY: a node of type TEXT_NODE is a Text node in any window.
+ text += (node as Text).data;
+ }
+ }
+ return stripWhitespace(text);
+ }).join("; ");
+}
+
+/** @see https://html.spec.whatwg.org/multipage/forms.html#category-label */
+function isLabelable(element: Element): boolean {
+ if (isHTML(element, "input")) {
+ return element.type !== "hidden";
+ }
+ const labelable = ["button", "meter", "output", "progress", "select", "textarea"] as const;
+ return labelable.some((name) => isHTML(element, name)) || isFormAssociatedCustom(element);
+}
+
+/**
+ * Validates every value before changing any control, so invalid input cannot partly fill the form.
+ * Writes follow input key order, which the page's input and change listeners can observe.
+ */
+function fillForm(form: HTMLFormElement, input: object): void {
+ const groups = controlsByName(form);
+ const writes = Object.entries(input).map(([name, value]) => {
+ const controls = groups.get(name);
+ const parameter = controls && readParameter(controls);
+ const write = parameter && prepareWrite(parameter, value);
+ if (!write) {
+ throw executionError();
+ }
+ return write;
+ });
+ for (const write of writes) {
+ write();
+ }
+}
+
+function prepareWrite(parameter: Parameter, value: unknown): (() => void) | undefined {
+ switch (parameter.kind) {
+ case "checkbox": {
+ const [checkbox] = parameter.controls;
+ return parameter.controls.length === 1
+ ? checkboxWrite(checkbox, value)
+ : choicesWrite(parameter.controls, value);
+ }
+ case "radio":
+ return radioWrite(parameter.controls, value);
+ case "select":
+ return selectWrite(parameter.control, value);
+ case "number":
+ case "range": {
+ const { control } = parameter;
+ // Chromium rejects empty values for numeric tool parameters.
+ const text = toText(value);
+ if (!text || !acceptsValue(control, text)) {
+ return undefined;
+ }
+ return () => setValue(control, text);
+ }
+ default: {
+ const { control } = parameter;
+ const text = toText(value);
+ if (text === undefined || (text !== "" && !acceptsValue(control, text))) {
+ return undefined;
+ }
+ return () => setValue(control, text);
+ }
+ }
+}
+
+function checkboxWrite(control: HTMLInputElement, value: unknown): (() => void) | undefined {
+ const checked = toBoolean(value);
+ return checked === undefined ? undefined : () => setChecked(control, checked);
+}
+
+function choicesWrite(controls: HTMLInputElement[], value: unknown): (() => void) | undefined {
+ const chosen = distinctChoices(value, controls.map((control) => control.value));
+ if (!chosen) {
+ return undefined;
+ }
+ return () => {
+ for (const control of controls) {
+ setChecked(control, chosen.has(control.value));
+ }
+ };
+}
+
+function radioWrite(controls: HTMLInputElement[], value: unknown): (() => void) | undefined {
+ const text = toText(value);
+ if (text === undefined || !controls.some((control) => control.value === text)) {
+ return undefined;
+ }
+ return () => {
+ for (const control of controls) {
+ if (control.value === text) {
+ setChecked(control, true);
+ }
+ }
+ };
+}
+
+function selectWrite(select: HTMLSelectElement, value: unknown): (() => void) | undefined {
+ const options = Array.from(select.options);
+ if (select.multiple) {
+ const chosen = distinctChoices(value, options.map((option) => option.value));
+ if (!chosen) {
+ return undefined;
+ }
+ return () => {
+ if (options.every((option) => option.selected === chosen.has(option.value))) {
+ return;
+ }
+ for (const option of options) {
+ setProperty(option, HTMLOptionElement.prototype, "selected", chosen.has(option.value));
+ }
+ dispatchInputAndChange(select);
+ };
+ }
+ const text = toText(value);
+ const option = options.find((candidate) => candidate.value === text);
+ if (!option) {
+ return undefined;
+ }
+ return () => {
+ const selectedIndex = select.selectedIndex;
+ setProperty(option, HTMLOptionElement.prototype, "selected", true);
+ if (select.selectedIndex !== selectedIndex) {
+ dispatchInputAndChange(select);
+ }
+ };
+}
+
+function distinctChoices(value: unknown, allowed: string[]): Set | undefined {
+ if (!Array.isArray(value)) {
+ return undefined;
+ }
+ const chosen = new Set();
+ for (const item of value) {
+ const text = toText(item);
+ if (text === undefined || !allowed.includes(text) || chosen.has(text)) {
+ return undefined;
+ }
+ chosen.add(text);
+ }
+ return chosen;
+}
+
+function setValue(control: HTMLInputElement | HTMLTextAreaElement, text: string): void {
+ const prototype = isHTML(control, "textarea")
+ ? HTMLTextAreaElement.prototype
+ : HTMLInputElement.prototype;
+ const before = control.value;
+ setProperty(control, prototype, "value", text);
+ if (control.value !== before && control.type !== "hidden") {
+ dispatchInputAndChange(control);
+ }
+}
+
+/** Fires change even for unchanged checkboxes and radios, matching Chromium's tool filling. */
+function setChecked(control: HTMLInputElement, checked: boolean): void {
+ const before = control.checked;
+ setProperty(control, HTMLInputElement.prototype, "checked", checked);
+ if (control.checked !== before) {
+ dispatchInputAndChange(control);
+ return;
+ }
+ control.dispatchEvent(new Event("change", { bubbles: true }));
+}
+
+function dispatchInputAndChange(control: Element): void {
+ control.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
+ control.dispatchEvent(new Event("change", { bubbles: true }));
+}
+
+/** Uses native value sanitization to check input without changing the live form. */
+function acceptsValue(control: HTMLInputElement | HTMLTextAreaElement, text: string): boolean {
+ if (!isHTML(control, "input")) {
+ return true;
+ }
+ const type = inputType(control);
+ const probe = control.ownerDocument.createElement("input");
+ probe.setAttribute("type", type);
+ if (probe.type !== type) {
+ return type === "month" ? isValidMonth(text) : isValidWeek(text);
+ }
+ setProperty(probe, HTMLInputElement.prototype, "value", text);
+ return probe.value !== "";
+}
+
+/** @see https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-month-string */
+function isValidMonth(text: string): boolean {
+ const [, year, month] = /^(\d{4,})-(\d{2})$/u.exec(text) ?? [];
+ return Number(year) > 0 && Number(month) >= 1 && Number(month) <= 12;
+}
+
+/** @see https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-week-string */
+function isValidWeek(text: string): boolean {
+ const [, year, week] = /^(\d{4,})-W(\d{2})$/u.exec(text) ?? [];
+ return Number(year) > 0 && Number(week) >= 1 && Number(week) <= weeksInYear(Number(year));
+}
+
+// A year has 53 weeks when it starts on a Thursday, or on a Wednesday in a leap year.
+function weeksInYear(year: number): number {
+ const start = new Date(0);
+ start.setUTCFullYear(year, 0, 1);
+ const weekday = start.getUTCDay();
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+ return weekday === 4 || (weekday === 3 && leap) ? 53 : 52;
+}
+
+function toText(value: unknown): string | undefined {
+ if (typeof value === "string") {
+ return value;
+ }
+ return typeof value === "number" || typeof value === "boolean" ? String(value) : undefined;
+}
+
+function toBoolean(value: unknown): boolean | undefined {
+ if (typeof value === "boolean") {
+ return value;
+ }
+ if (typeof value === "number") {
+ return Number.isInteger(value) ? value !== 0 : undefined;
+ }
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const text = value.toLowerCase();
+ if (text === "true" || text === "1") {
+ return true;
+ }
+ return text === "false" || text === "0" ? false : undefined;
+}
+
+/** Produces "undefined" when JSON serialization returns undefined, as V8's JSON::Stringify does. */
+function serializeResponse(response: unknown): string {
+ return String(isObject(response) ? JSON.stringify(response) : response);
+}
+
+/**
+ * Reads form members that named controls may shadow on the instance.
+ * @see https://webidl.spec.whatwg.org/#LegacyOverrideBuiltIns
+ */
+function formMember(
+ form: HTMLFormElement,
+ name: Name,
+): HTMLFormElement[Name] {
+ // SAFETY: the prototype chain holds the member that the form's named properties would shadow.
+ return Reflect.get(HTMLFormElement.prototype, name, form) as HTMLFormElement[Name];
+}
+
+/** Searches the document because form.elements omits image buttons. */
+function defaultButton(
+ owner: Document,
+ form: HTMLFormElement,
+): HTMLButtonElement | HTMLInputElement | undefined {
+ const candidates = owner.querySelectorAll("button, input");
+ for (const candidate of candidates) {
+ const isSubmitButton = candidate.type === "submit" || candidate.type === "image";
+ if (candidate.form === form && isSubmitButton && !candidate.matches(":disabled")) {
+ return candidate;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Native matching uses the form association captured when the element was defined.
+ * @see https://html.spec.whatwg.org/multipage/semantics-other.html#selector-enabled
+ */
+function isFormAssociatedCustom(element: Element): boolean {
+ // Match first: a form may shadow both matches and localName with named controls.
+ return (
+ Element.prototype.matches.call(element, ":enabled, :disabled") &&
+ element.localName.includes("-")
+ );
+}
+
+const htmlNamespace = "http://www.w3.org/1999/xhtml";
+
+/** Identifies HTML elements across windows; instanceof fails for adopted nodes. */
+function isHTML(
+ element: Element,
+ name: Name,
+): element is HTMLElementTagNameMap[Name] {
+ // SAFETY: an HTML element with this local name implements the named interface.
+ return element.namespaceURI === htmlNamespace && element.localName === name;
+}
+
+/** Bypasses framework value trackers so they detect the change when the input event arrives. */
+function setProperty<
+ Control extends Element,
+ Name extends keyof Control & ("value" | "checked" | "selected"),
+>(
+ element: Control,
+ prototype: object,
+ name: Name,
+ value: Control[Name],
+): void {
+ // SAFETY: callers name value, checked or selected, accessors with setters on these prototypes.
+ Object.getOwnPropertyDescriptor(prototype, name)!.set!.call(element, value);
+}
diff --git a/src/frames.ts b/src/frames.ts
index 1458b1f..5d67a7d 100644
--- a/src/frames.ts
+++ b/src/frames.ts
@@ -1,12 +1,5 @@
import type { WebMCP } from "webmcp-types";
-
-/** Tool metadata as its owner stores it and as frames exchange it. */
-export interface ToolMetadata
- extends Pick {
- annotations: WebMCP.ToolAnnotations | undefined;
- // Snapshot at registration; each discovery result parses a fresh copy.
- serializedSchema: string | undefined;
-}
+import { NativeDOMException, executionError, type ToolMetadata } from "./tools.js";
// Lexicographical, the order in which Web IDL reads dictionary members.
export const annotationNames = [
@@ -23,7 +16,7 @@ interface Handlers {
name: string,
serializedInput: string,
signal: AbortSignal,
- ): Promise;
+ ): Promise;
changed(): Promise;
}
@@ -33,7 +26,7 @@ type Request =
| { kind: "execute"; name: string; input: string }
| { kind: "changed" };
-type ReplyValue = boolean | ToolMetadata[] | string | undefined;
+type ReplyValue = boolean | ToolMetadata[] | string | null | undefined;
interface Session {
peer: Window;
@@ -43,7 +36,6 @@ interface Session {
const protocol = "webmcp-polyfill";
// Bounds handshakes, discovery, and permission replies; author code has no deadline.
const deadline = 500;
-const NativeDOMException = globalThis.DOMException;
export class FrameBridge {
readonly #document: Document;
@@ -135,14 +127,15 @@ export class FrameBridge {
name: string,
serializedInput: string,
signal?: AbortSignal,
- ): Promise {
+ ): Promise {
const reply = await this.#request(
target,
[expectedOrigin],
{ kind: "execute", name, input: serializedInput },
signal,
);
- if (typeof reply.value !== "string") {
+ // A declarative tool whose form navigates has a null result.
+ if (typeof reply.value !== "string" && reply.value !== null) {
throw executionError();
}
return reply.value;
@@ -767,7 +760,3 @@ function isWindow(value: MessageEventSource): value is Window {
return false;
}
}
-
-export function executionError(): DOMException {
- return new NativeDOMException("Tool execution failed", "UnknownError");
-}
diff --git a/src/index.ts b/src/index.ts
index f4974bb..8f17788 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,27 +4,24 @@
*/
import type { WebMCP } from "webmcp-types";
+import { DeclarativeTools, installDeclarative } from "./declarative.js";
import { ToolActivatedEvent, ToolCancelEvent } from "./events.js";
+import { FrameBridge, activeWindow, annotationNames, readToolsPolicy } from "./frames.js";
import {
- FrameBridge,
- activeWindow,
- annotationNames,
+ NativeDOMException,
+ canDefine,
executionError,
- readToolsPolicy,
+ isObject,
+ queueTask,
+ toolNamePattern,
+ type StoredTool,
type ToolMetadata,
-} from "./frames.js";
+} from "./tools.js";
export type { WebMCP } from "webmcp-types";
-// Detached windows may stop exposing these bindings.
-const NativeDOMException = globalThis.DOMException;
+// Detached windows may stop exposing this binding.
const getWindow = Object.getOwnPropertyDescriptor(globalThis, "window")?.get;
-interface StoredTool {
- metadata: ToolMetadata;
- execute: WebMCP.ToolExecuteCallback