From 35413d6d327f12b4541eac53d8ffad2145a6c7b7 Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Thu, 1 Oct 2026 21:40:03 -0700 Subject: [PATCH 1/7] feat(polyfill): add declarative tools Forms with toolname and tooldescription become tools. The draft's declarative section is a TODO, so they follow the declarative API explainer and Chromium at dbdbb13. Installation adds agentInvoked and respondWith() to SubmitEvent.prototype and wraps HTMLFormElement.prototype.submit(). Validation: 334 browser tests and the packed consumer checks pass. WPT in Chrome Canary 157.0.8082.0 and Firefox Nightly 159.0a1: 70 files, 190 subtests, zero unexpected results. Safari cannot close the test window after a form submits into an about:blank iframe, which a page without the polyfill reproduces. --- README.md | 31 +- TESTING.md | 61 +- src/declarative.ts | 1045 ++++++++++++++ src/frames.ts | 23 +- src/index.ts | 129 +- src/tools.ts | 55 + tests/declarative.test.ts | 1228 +++++++++++++++++ tests/draft-members.d.ts | 9 + tests/fixtures/schemas.ts | 239 ++++ tests/frames.test.ts | 110 ++ tests/index.test.ts | 13 + tests/native.test.ts | 13 + tests/package.test.ts | 4 + .../duplicate-tool-name.https.html.ini | 7 - .../executeTool-abort.https.html.ini | 6 +- ...teTool-detach-toolactivated.https.html.ini | 3 +- .../executeTool-flexible-types.https.html.ini | 5 - .../executeTool-invalid-input.https.html.ini | 11 - .../executeTool-navigation.https.html.ini | 5 - .../executeTool-no-autosubmit.https.html.ini | 11 +- .../executeTool-pseudo-classes.https.html.ini | 11 +- ...respondWith-circular-object.https.html.ini | 5 - ...cuteTool-respondWith-reject.https.html.ini | 5 - .../executeTool-respondWith.https.html.ini | 16 - .../execute_tool_change_event.https.html.ini | 5 - ...execute_tool_submit_from_js.https.html.ini | 5 - .../form_removal_submit_crash.https.html.ini | 5 - ...getTools-declarative-schema.https.html.ini | 5 - .../no-frame-documents.https.html.ini | 5 +- .../opaque-origin-tools.https.html.ini | 7 - .../sandboxed-iframe.https.html.ini | 4 +- .../select-multiple-events.https.html.ini | 5 - ...hange-on-attribute-mutation.https.html.ini | 10 - ...hange-on-control-add-remove.https.html.ini | 5 - .../toolchange-on-name-change.https.html.ini | 5 - ...register-during-executeTool.https.html.ini | 7 - 36 files changed, 2887 insertions(+), 226 deletions(-) create mode 100644 src/declarative.ts create mode 100644 src/tools.ts create mode 100644 tests/declarative.test.ts create mode 100644 tests/draft-members.d.ts create mode 100644 tests/fixtures/schemas.ts delete mode 100644 wpt/metadata/webmcp/declarative/duplicate-tool-name.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/executeTool-flexible-types.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/executeTool-invalid-input.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/executeTool-navigation.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/executeTool-respondWith-circular-object.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/executeTool-respondWith-reject.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/executeTool-respondWith.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/execute_tool_change_event.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/execute_tool_submit_from_js.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/form_removal_submit_crash.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/getTools-declarative-schema.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/opaque-origin-tools.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/select-multiple-events.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/toolchange-on-attribute-mutation.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/toolchange-on-control-add-remove.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/toolchange-on-name-change.https.html.ini delete mode 100644 wpt/metadata/webmcp/declarative/unregister-during-executeTool.https.html.ini diff --git a/README.md b/README.md index 548509d..02e2082 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 and read-only controls are left out. Same-named checkboxes or radio buttons are 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: @@ -93,11 +120,11 @@ Initial discovery waits up to 500 ms for existing frames. Requests use `MessageC ## 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. diff --git a/TESTING.md b/TESTING.md index 8ea8b4f..619bfc9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -68,13 +68,62 @@ 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. +- **Draft differences:** callback 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. +- **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. + - 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 +141,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..0313067 --- /dev/null +++ b/src/declarative.ts @@ -0,0 +1,1045 @@ +import { + NativeDOMException, + canDefine, + executionError, + isObject, + queueTask, + toolNamePattern, + type StoredTool, +} from "./tools.js"; + +/** The tool map that declarative tools share with registerTool(), and its change notification. */ +interface ToolHost { + tools: Map; + changed(): void; +} + +interface FormDefinition { + name: string; + title: string; + description: string; + autosubmit: boolean; + serializedSchema: string; + declaration: string; +} + +interface PendingSubmission { + form: HTMLFormElement; + // Waiting for the form's submission, then submitting from the agent's submit event until the + // polyfill settles it in a later task. The call may then still wait for the page's response. + phase: "waiting" | "submitting" | "handled"; + response?: Promise; + resolve(result: unknown): void; + reject(): void; +} + +// A submitted form navigates instead of responding; executeTool() then resolves 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 + */ +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 converts the argument to a promise before the method's steps. + const response = Promise.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", + ); + } + 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); + // As in Chromium, submitting a form from script completes its running tool calls. + 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); +} + +// A capture listener that the page added before installation runs before the polyfill's own, +// so the event's members also recognize an agent's submission. +function submissionOf(event: SubmitEvent): PendingSubmission | undefined { + documentTools.get(document)?.submitting(event); + return submissions.get(event); +} + +/** + * Declarative tools: forms with `toolname` and `tooldescription` attributes. + * + * The draft's declarative section is a TODO, so this follows the declarative API explainer + * and Chromium's form_mcp_schema.cc and html_form_element.cc at dbdbb13fd74c. + */ +export class DeclarativeTools { + readonly #document: Document; + readonly #host: ToolHost; + // The definition each form registered, while it holds that name in the host's map. + readonly #registrations = new Map(); + // Unfinished calls. A form has at most one call that waits for its 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)); + // Each mutation batch rereads every form, not only those the records touched. + new MutationObserver(() => this.update()).observe(this.#document, { + subtree: true, + childList: true, + characterData: true, + attributeFilter: definingAttributes, + }); + this.update(); + } + + // Unregisters forms that left the document or lost a tool attribute. Chromium does this + // synchronously, so script can reuse their names before mutation observers run; it replaces + // other changed forms in a later task. + release(): void { + if (this.#release(true)) { + this.#host.changed(); + } + } + + // Registers forms that became tools and replaces or removes those that changed. + update(): void { + let changed = this.#release(); + // 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); + } + } + + // Chromium treats every trusted submission of a waiting form as the agent's. An event can + // become one only once, and only while it is being dispatched. + 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)); + } + + #release(onlyInvalid = 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 (onlyInvalid ? definition !== undefined : unchanged) { + continue; + } + // Chromium replaces a changed form's tool in one task, so a form that keeps its name keeps + // the 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; + // Removal and tool attribute changes cancel the form's calls, as in the explainer; a schema + // change only replaces the tool. Chromium keeps a call whose form changes during the agent's + // submit event; the polyfill notices those changes later, so it keeps a submitting call. + if (!sameDeclaration) { + for (const pending of this.#callsOf(form)) { + if (pending.phase !== "submitting") { + 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)), + }; + } + + // toolactivated fires between filling and submitting, as the explainer describes; Chromium + // fires it after submitting. + #execute( + form: HTMLFormElement, + autosubmit: boolean, + input: object, + signal: AbortSignal, + activate: () => void, + ): Promise { + // Chromium's input is a JSON object, and without autosubmit the user submits with a button. + 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 }); + + activate(); + // A toolactivated listener may have settled the call or submitted the form itself. + if (!this.#pending.has(pending) || pending.phase !== "waiting") { + return; + } + // Listeners may have replaced or disabled the button while the form was filled. + 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 { + // Until now, respondWith() works even after a listener resets the form or submits it from + // script; Chromium also accepts that response during dispatch, then ignores it. + pending.phase = "handled"; + if (!this.#pending.has(pending)) { + return; + } + if (pending.response) { + pending.response.then(pending.resolve, pending.reject); + } else if (event.defaultPrevented) { + pending.reject(); + } else { + pending.resolve(navigated); + } + } + + #resetting(event: Event): void { + if (event.isTrusted && !event.defaultPrevented) { + 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 ControlKind = + | "text" + | "date" + | "datetime-local" + | "month" + | "week" + | "time" + | "number" + | "range" + | "checkbox" + | "radio" + | "color" + | "select"; + +type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +type Parameter = + | { kind: "checkbox" | "radio"; controls: HTMLInputElement[] } + | { kind: "select"; control: HTMLSelectElement } + | { + kind: Exclude; + control: HTMLInputElement | HTMLTextAreaElement; + }; + +// Attributes that can change a form's tool definition; text changes can too (labels, options). +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]), + }; +} + +// Properties keep the order in which their names first appear, as in Chromium. +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 name.trim(); +} + +// Input types to which the readonly attribute applies. +const readOnlyTypes = new Set([ + "text", + "search", + "url", + "tel", + "email", + "password", + "date", + "month", + "week", + "time", + "datetime-local", + "number", +]); + +// Chromium skips disabled controls, and readonly ones where readonly applies. +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)) + ); +} + +// A parameter is one control of a supported kind, or a group of only checkboxes or radios. +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: controlKind() gives each kind only to the element types Parameter declares for it. + return (isGroup ? { kind, controls } : { kind, control: controls[0] }) as Parameter; +} + +function controlKind(element: Element): ControlKind | 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; + } +} + +// Firefox and Safari have no month or week inputs, but the author's type still applies. +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 }; +} + +// Chromium includes a pattern only on input elements, and only if it compiles with the v flag. +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 }), + }; +} + +// 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 }; +} + +// Chromium states the step only when the step base is also a multiple of it. +function multipleOf(control: Element): { multipleOf?: number } { + const step = parseStep(control, 1); + return step !== undefined && isMultiple(stepBase(control), step) ? { multipleOf: step } : {}; +} + +// Chromium varies the time formats with the step to suggest the precision it accepts. It rounds +// the step to whole milliseconds, at least one, and treats "any" as the default minute. +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])?" : ""; +} + +function parameterDescription(parameter: Parameter): string | undefined { + const controls: FormControl[] = + "controls" in parameter ? parameter.controls : [parameter.control]; + const [control] = controls; + if (control && controls.length === 1) { + return ( + control.getAttribute("toolparamdescription") || + labelText(control) || + control.getAttribute("aria-description") || + undefined + ); + } + // A group is described only by the nearest fieldset around all of its controls. + return commonFieldset(controls)?.getAttribute("toolparamdescription") || undefined; +} + +function commonFieldset(controls: FormControl[]): HTMLFieldSetElement | undefined { + const [first] = controls; + let ancestor: Element | null = first ?? null; + for (const control of controls) { + // The ancestor can be the form, whose controls may shadow the members it inherits from Node. + 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; + for (let element = ancestor; element && element !== form; element = element.parentElement) { + if (isHTML(element, "fieldset")) { + return element; + } + } + 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 text.trim(); + }).join("; "); +} + +// 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); +} + +// Chromium's FormMCPSchema::FillData: every value is checked before any control changes, and +// controls change in the input's key order. +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 checkbox && 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; + // Numbers cannot be cleared. + const text = toText(value); + return text && acceptsValue(control, text) ? () => setValue(control, text) : undefined; + } + 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); + } +} + +// Chromium fires change at a checkbox or radio even when its checkedness stays. +function setChecked(control: HTMLInputElement, checked: boolean): void { + const before = control.checked; + setProperty(control, HTMLInputElement.prototype, "checked", checked); + if (control.checked !== before) { + dispatchInputAndChange(control); + } else { + 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 })); +} + +// Chromium's value sanitization check, on a detached input of the same type. +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 !== ""; +} + +// 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; +} + +// 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; +} + +// https://html.spec.whatwg.org/multipage/input.html#concept-input-step +// Undefined means "any". +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; +} + +// https://html.spec.whatwg.org/multipage/input.html#concept-input-min-zero +function stepBase(control: Element): number { + const minimum = parseNumber(control.getAttribute("min")); + return minimum ?? parseNumber(control.getAttribute("value")) ?? 0; +} + +// Chromium divides exact decimals; 12 significant digits absorb binary error for realistic steps. +function isMultiple(value: number, step: number): boolean { + return Number.isInteger(Number((value / step).toPrecision(12))); +} + +// 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; +} + +// Chromium's ToString: strings, numbers and booleans. +function toText(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + return typeof value === "number" || typeof value === "boolean" ? String(value) : undefined; +} + +// Chromium's ToBoolean: booleans, integers, and "true", "false", "1" or "0" in any case. +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; +} + +// Chromium stringifies an object response as JSON and converts any other value to a string. +// String() turns an object that JSON leaves undefined into "undefined", as V8's JSON::Stringify. +function serializeResponse(response: unknown): string { + return String(isObject(response) ? JSON.stringify(response) : response); +} + +// A control named like a form member shadows it on the form ([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]; +} + +// The first enabled submit button in tree order: Chromium focuses it or submits with it. +// form.elements leaves out image buttons, so the search covers the document. +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; +} + +// customElements.define() converts a constructor's formAssociated to a boolean. +function isFormAssociatedCustom(element: Element): boolean { + const definition = customElements.get(element.localName); + return definition !== undefined && Boolean(Reflect.get(definition, "formAssociated")); +} + +const htmlNamespace = "http://www.w3.org/1999/xhtml"; + +// An element's local name identifies it in any window; 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; +} + +// Prototype setters bypass page overrides and the value trackers of frameworks such as React, +// which then see the change when the input event arrives. +function setProperty(element: Element, prototype: object, name: string, value: unknown): 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..63cc3ca 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; - exposedTo: string[]; -} - const contexts = new WeakMap(); /** @@ -60,17 +57,15 @@ export function installWebMCP(): void { 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) || - !Object.keys(interfaceObjects).every(canDefineInterface) + !Object.keys(interfaceObjects).every((name) => canDefine(globalThis, name)) ) { throw new TypeError("Cannot install WebMCP on this realm"); } + // Declarative tools patch other prototypes, so they go first: a refusal leaves nothing installed. + installDeclarative(); for (const [name, value] of Object.entries(interfaceObjects)) { Object.defineProperty(globalThis, name, { value, configurable: true, writable: true }); } @@ -102,6 +97,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { readonly #tools = new Map(); // Only a document that was active when its context was created has a bridge. readonly #frames?: FrameBridge; + #declarative?: DeclarativeTools; readonly #eventHandlers: EventHandlers = { ontoolchange: null, ontoolactivated: null, @@ -136,6 +132,8 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { }, changed: () => this.#queueToolChange(), }); + // Forms become tools as soon as a policy check passes. + this.#requireFrames().catch(() => {}); } get ontoolchange(): WebMCP.ModelContext["ontoolchange"] { @@ -177,6 +175,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { requireActiveWindow(ownerDocument); const requireUnusedName = (): void => { + this.#declarative?.release(); if (this.#tools.has(name)) { throw new NativeDOMException( `A tool named ${name} is already registered`, @@ -187,7 +186,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { // Duplicate, then name, then description: the draft's order. requireUnusedName(); - if (!/^[A-Za-z0-9_.-]{1,128}$/u.test(name)) { + if (!toolNamePattern.test(name)) { throw new NativeDOMException( `Tool names are 1 to 128 characters of ASCII alphanumerics, "_", "-" or ".": ${name}`, "InvalidStateError", @@ -202,8 +201,13 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { const storedTool: StoredTool = { metadata: { name, title, description, annotations, serializedSchema }, - execute, exposedTo: exposedOrigins, + run(input, signal, activate) { + activate(); + // The callback runs without the registration object as its receiver. + return execute(input, { signal }); + }, + serialize: serializeJSON, }; const frames = await this.#requireFrames(); @@ -218,6 +222,8 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { () => { this.#tools.delete(name); void frames.notify(exposedOrigins); + // A form waiting for this name can claim it now. + this.#declarative?.update(); reject(registrationSignal.reason); }, { once: true }, @@ -279,23 +285,19 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { const frames = await this.#requireFrames(); callerSignal?.throwIfAborted(); const callerWindow = requireActiveWindow(ownerDocument); - if (target.window !== callerWindow) { - return frames.execute( - target.window, - expectedOrigin, - target.name, - serializedInput, - callerSignal, - ); - } - - return this.#executeLocal( - target.name, - serializedInput, - expectedOrigin, - callerWindow.origin, - callerSignal, - ); + const result = + target.window === callerWindow + ? this.#executeLocal( + target.name, + serializedInput, + expectedOrigin, + callerWindow.origin, + callerSignal, + ) + : frames.execute(target.window, expectedOrigin, target.name, serializedInput, callerSignal); + // SAFETY: only a declarative tool whose form navigates resolves null, as in Chromium and WPT; + // the draft and webmcp-types declare a string. + return result as Promise; } #executeLocal( @@ -304,8 +306,8 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { expectedOrigin: string, callerOrigin: string, callerSignal?: AbortSignal, - ): Promise { - return new Promise((resolve, reject) => { + ): Promise { + return new Promise((resolve, reject) => { callerSignal?.throwIfAborted(); const callbackController = new AbortController(); // The draft's local pending tool execution: it exists from invocation until the callback @@ -334,7 +336,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { }); }; - const completeExecution = (value: unknown): void => { + const completeExecution = (storedTool: StoredTool, value: unknown): void => { callbackPending = false; // A cancelled call must not run the author's toJSON during serialization. if (callerSignal?.aborted) { @@ -342,7 +344,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { } try { - const serializedResult = serializeJSON(value); + const serializedResult = storedTool.serialize(value); queueTask(() => { callerSignal?.removeEventListener("abort", onCallerAbort); resolve(serializedResult); @@ -375,14 +377,16 @@ 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 }); - Promise.resolve(callbackResult).then(completeExecution, rejectExecution); + const activate = (): void => { + // Listener exceptions are reported, not thrown, and the callback still runs. + this.dispatchEvent(new ToolActivatedEvent("toolactivated", { toolName: name })); + callbackPending = true; + }; + const callbackResult = storedTool.run(input, callbackController.signal, activate); + Promise.resolve(callbackResult).then( + (value) => completeExecution(storedTool, value), + rejectExecution, + ); } catch { rejectExecution(); } @@ -393,12 +397,18 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { }); } + // The first check that passes also turns the document's forms into tools. In a cross-origin + // frame, that can be a later check, once its ancestors have installed the polyfill. async #requireFrames(): Promise { const frames = this.#frames; if (!frames || !(await frames.allowed())) { throw new NativeDOMException("WebMCP is disabled by Permissions Policy", "NotAllowedError"); } - requireActiveWindow(this.#document); + const view = requireActiveWindow(this.#document); + this.#declarative ??= new DeclarativeTools(view, { + tools: this.#tools, + changed: () => void frames.notify([]), + }); return frames; } @@ -521,10 +531,6 @@ function readInputSchema(value: unknown): object | undefined { return value; } -function isObject(value: unknown): value is object { - return (typeof value === "object" && value !== null) || typeof value === "function"; -} - // https://webidl.spec.whatwg.org/#es-dictionary function readDictionary(value: unknown): Record { if (value == null) { @@ -666,18 +672,3 @@ function requireActiveWindow(owner: Document): Window { } return view; } - -// Message tasks approximate the WebMCP task source. Chained zero-delay timers are clamped to -// 4 ms, and one port keeps the tasks in order. Created on first use so an import stays inert. -let taskPort: MessagePort | undefined; -const queuedTasks: (() => void)[] = []; - -function queueTask(callback: () => void): void { - if (!taskPort) { - const channel = new MessageChannel(); - channel.port1.onmessage = () => queuedTasks.shift()?.(); - taskPort = channel.port2; - } - queuedTasks.push(callback); - taskPort.postMessage(undefined); -} diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..1f9a905 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,55 @@ +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; +} + +/** A tool as its owner stores it, whether registered by script or declared by a form. */ +export interface StoredTool { + metadata: ToolMetadata; + // Origins other than the owner's that may discover and execute the tool. + exposedTo: string[]; + // The draft's execute steps. run() calls activate(), which fires toolactivated, before it + // returns; if run() throws first, the call fails without the event. + run(input: object, signal: AbortSignal, activate: () => void): unknown; + // Converts the value run() returns, or its promise fulfills with, into the result. + serialize(result: unknown): string | null; +} + +export const toolNamePattern = /^[A-Za-z0-9_.-]{1,128}$/u; + +// Detached windows may stop exposing this binding. +export const NativeDOMException = globalThis.DOMException; + +export function isObject(value: unknown): value is object { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +export function executionError(): DOMException { + return new NativeDOMException("Tool execution failed", "UnknownError"); +} + +// Installation replaces a page's configurable property, but must not fail halfway through. +export function canDefine(target: object, name: string): boolean { + const descriptor = Object.getOwnPropertyDescriptor(target, name); + return descriptor ? descriptor.configurable === true : Object.isExtensible(target); +} + +// Message tasks approximate the WebMCP task source. Chained zero-delay timers are clamped to +// 4 ms, and one port keeps the tasks in order. Created on first use so an import stays inert. +let taskPort: MessagePort | undefined; +const queuedTasks: (() => void)[] = []; + +export function queueTask(callback: () => void): void { + if (!taskPort) { + const channel = new MessageChannel(); + channel.port1.onmessage = () => queuedTasks.shift()?.(); + taskPort = channel.port2; + } + queuedTasks.push(callback); + taskPort.postMessage(undefined); +} diff --git a/tests/declarative.test.ts b/tests/declarative.test.ts new file mode 100644 index 0000000..cdfb44d --- /dev/null +++ b/tests/declarative.test.ts @@ -0,0 +1,1228 @@ +import { test, expect } from "@playwright/test"; +import { chromiumSchemas } from "./fixtures/schemas.js"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + expect(await page.evaluate(() => "modelContext" in document)).toBe(false); + await page.addScriptTag({ url: "/auto.js" }); +}); + +test("a form with a tool name and description becomes a discoverable tool", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + const changed = new Promise((resolve) => { + context.addEventListener("toolchange", resolve, { once: true }); + }); + document.body.insertAdjacentHTML( + "beforeend", + `
+ + + +
`, + ); + await changed; + const tools = await context.getTools(); + return tools.map(({ window: owner, ...tool }) => ({ ...tool, ownWindow: owner === window })); + }); + + expect(outcome).toEqual([ + { + name: "search_tool", + title: "", + description: "Search the web", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "The search query" }, + limit: { type: "number", multipleOf: 1, description: "Max results count" }, + safe_search: { type: "boolean", description: "Enable safe search filtering" }, + }, + required: ["query"], + }, + origin: "http://localhost:8793", + ownWindow: true, + }, + ]); +}); + +test("form schemas match Chromium's for every supported control", async ({ page }) => { + const schemas = await page.evaluate(async (cases) => { + const context = document.modelContext!; + const results: Record = {}; + for (const { name, html } of cases) { + document.body.innerHTML = html; + const [tool] = await context.getTools(); + results[name] = JSON.stringify(tool?.inputSchema, null, 2); + } + return results; + }, chromiumSchemas); + + // Chromium's tests and WPT's schema helper compare serialized JSON, so key order matters. + expect(schemas).toEqual( + Object.fromEntries( + chromiumSchemas.map(({ name, schema }) => [name, JSON.stringify(schema, null, 2)]), + ), + ); +}); + +test("a form's tool follows its definition, with one toolchange per change", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + let changes = 0; + context.addEventListener("toolchange", () => changes++); + const form = document.createElement("form"); + form.setAttribute("toolname", "my_tool"); + form.setAttribute("tooltitle", "My Title"); + form.setAttribute("tooldescription", "desc"); + const queryLabel = document.createElement("label"); + queryLabel.htmlFor = "query"; + queryLabel.textContent = "Query"; + const input = document.createElement("input"); + input.id = "query"; + input.name = "query"; + form.append(queryLabel, input); + + const steps: [string, () => void][] = [ + ["insert", () => document.body.append(form)], + ["title", () => form.setAttribute("tooltitle", "New Title")], + ["description", () => form.setAttribute("tooldescription", "")], + ["name", () => form.setAttribute("toolname", "new_name")], + ["no title", () => form.removeAttribute("tooltitle")], + ["autosubmit", () => form.setAttribute("toolautosubmit", "")], + ["unrelated attribute", () => input.setAttribute("data-unrelated", "value")], + ["same control again", () => form.append(input)], + ["same value", () => form.setAttribute("toolname", "new_name")], + ["control type", () => (input.type = "number")], + ["label text", () => ((queryLabel.firstChild as Text).data = "Search query")], + ["no description", () => form.removeAttribute("tooldescription")], + [ + "invalid name", + () => { + form.setAttribute("tooldescription", "desc"); + form.setAttribute("toolname", "not valid"); + }, + ], + ]; + const results = []; + for (const [label, mutate] of steps) { + const before = changes; + mutate(); + // Discovery resolves after any toolchange that the mutation queued. + const tools = await context.getTools(); + results.push({ + label, + changes: changes - before, + tools: tools.map(({ name, title, description }) => ({ name, title, description })), + }); + } + return results; + }); + + const tool = (name: string, title: string, description: string) => [{ name, title, description }]; + expect(outcome).toEqual([ + { label: "insert", changes: 1, tools: tool("my_tool", "My Title", "desc") }, + { label: "title", changes: 1, tools: tool("my_tool", "New Title", "desc") }, + { label: "description", changes: 1, tools: tool("my_tool", "New Title", "") }, + { label: "name", changes: 1, tools: tool("new_name", "New Title", "") }, + { label: "no title", changes: 1, tools: tool("new_name", "", "") }, + { label: "autosubmit", changes: 1, tools: tool("new_name", "", "") }, + { label: "unrelated attribute", changes: 0, tools: tool("new_name", "", "") }, + { label: "same control again", changes: 0, tools: tool("new_name", "", "") }, + { label: "same value", changes: 0, tools: tool("new_name", "", "") }, + { label: "control type", changes: 1, tools: tool("new_name", "", "") }, + { label: "label text", changes: 1, tools: tool("new_name", "", "") }, + { label: "no description", changes: 1, tools: [] }, + { label: "invalid name", changes: 0, tools: [] }, + ]); +}); + +test("the first form or script tool to claim a name holds it until it is removed", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + let changes = 0; + context.addEventListener("toolchange", () => changes++); + const describe = async () => + (await context.getTools()).map((tool) => `${tool.name}: ${tool.description}`); + const script = { name: "shared", description: "script", execute() {} }; + + document.body.innerHTML = ` +
+
`; + const claimed = { tools: await describe(), changes }; + const held = await context.registerTool(script).catch((error) => error.name); + + // Chromium would register this form only once it changes. + document.forms[0]!.remove(); + const promoted = await describe(); + + // Script can reuse the name right after a removal, before mutation observers run. + document.forms[0]!.remove(); + const registration = new AbortController(); + const reused = await context + .registerTool(script, { signal: registration.signal }) + .then(() => "registered"); + + document.body.innerHTML = `
`; + const blocked = await describe(); + registration.abort(); + const released = await describe(); + + // Until mutation observers run, a renamed form keeps its name, even if the new one is invalid. + document.forms[0]!.setAttribute("toolname", "not valid"); + const renamed = await context.registerTool(script).catch((error) => error.name); + + // A form that changes keeps its name, even after a form before it claims the same name. + document.body.innerHTML = `
`; + await describe(); + document.body.insertAdjacentHTML( + "afterbegin", + `
`, + ); + await describe(); + document.forms[1]!.insertAdjacentHTML("beforeend", ``); + const kept = await describe(); + return { claimed, held, promoted, reused, blocked, released, renamed, kept }; + }); + + expect(outcome).toEqual({ + claimed: { tools: ["shared: first"], changes: 1 }, + held: "InvalidStateError", + promoted: ["shared: second"], + reused: "registered", + blocked: ["shared: script"], + released: ["shared: waiting"], + renamed: "InvalidStateError", + kept: ["shared: holder"], + }); +}); + +test("forms in the page register at installation, but not forms of other documents", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + document.body.innerHTML = `
`; + }); + await page.addScriptTag({ url: "/auto.js" }); + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + const installed = (await context.getTools()).map((tool) => tool.name); + const markup = `
`; + document.implementation.createHTMLDocument().body.innerHTML = markup; + new DOMParser().parseFromString(markup, "text/html"); + const template = document.createElement("template"); + template.innerHTML = markup; + document.body.append(template); + const afterOtherDocuments = (await context.getTools()).map((tool) => tool.name); + return { installed, afterOtherDocuments }; + }); + + expect(outcome).toEqual({ installed: ["early"], afterOtherDocuments: ["early"] }); +}); + +test("a form adopted by another document unregisters", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
`; + const before = (await context.getTools()).map((tool) => tool.name); + const frame = document.querySelector("iframe")!; + frame.contentDocument!.body.append(document.forms[0]!); + const after = (await context.getTools()).map((tool) => tool.name); + return { before, after }; + }); + + expect(outcome).toEqual({ before: ["moved"], after: [] }); +}); + +test("controls named like form members do not shadow them", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+ + + +
+
`; + const tools = await context.getTools(); + const shadowed = tools.find((tool) => tool.name === "shadowed")!; + document.forms[0]!.addEventListener("submit", (event) => { + event.preventDefault(); + event.respondWith(Promise.resolve("submitted")); + }); + return { + names: tools.map((tool) => tool.name), + properties: Object.keys((shadowed.inputSchema as { properties: object }).properties), + result: await context.executeTool(shadowed, { elements: "1" }).catch((error) => error.name), + }; + }); + + expect(outcome).toEqual({ + names: ["after", "shadowed"], + properties: ["elements", "getAttribute", "requestSubmit", "contains", "scope"], + result: "submitted", + }); +}); + +test("executing an autosubmit form fills it, then submits for the page's response", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+ + +
`; + const form = document.forms[0]!; + const input = form.elements[0] as HTMLInputElement; + const order: string[] = []; + for (const type of ["input", "change"]) { + input.addEventListener(type, () => order.push(`${type} ${input.value}`)); + } + context.addEventListener("toolactivated", () => order.push(`toolactivated ${input.value}`)); + form.addEventListener("submit", (event) => { + order.push(`submit ${event.agentInvoked}`); + event.preventDefault(); + event.respondWith(Promise.resolve("found it")); + }); + + const [tool] = await context.getTools(); + const result = await context.executeTool(tool!, { query: "testing" }); + return { result, order }; + }); + + expect(outcome).toEqual({ + result: "found it", + order: ["input testing", "change testing", "toolactivated testing", "submit true"], + }); +}); + +test("filling converts values and fires input and change events like Chromium", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+ + + + + + + + +
`; + const form = document.forms[0]!; + const events: string[] = []; + for (const type of ["input", "change"]) { + form.addEventListener(type, (event) => { + const control = event.target as HTMLInputElement; + const value = ["radio", "checkbox"].includes(control.type) ? `=${control.value}` : ""; + events.push(`${type} ${control.name}${value}`); + }); + } + form.addEventListener("submit", (event) => { + event.preventDefault(); + event.respondWith(Promise.resolve("done")); + }); + const [tool] = await context.getTools(); + const entries = () => Array.from(new FormData(form), ([name, value]) => `${name}=${value}`); + + const input = { + text: 123, + count: "456", + agree: 1, + size: "m", + tags: ["a"], + pick: 2, + fruits: ["apple", "banana"], + token: "secret", + }; + await context.executeTool(tool!, input); + const first = { entries: entries(), events: events.splice(0) }; + await context.executeTool(tool!, input); + const repeated = events.splice(0); + await context.executeTool(tool!, { agree: "false", tags: ["b"] }); + const changed = { entries: entries(), events: events.splice(0) }; + // Chromium formats a non-integer with six significant digits, filling 1.23457. + await context.executeTool(tool!, { count: 1.2345678, agree: "TRUE" }); + const converted = entries(); + return { first, repeated, changed, converted }; + }); + + expect(outcome.first).toEqual({ + entries: [ + "text=123", + "count=456", + "agree=on", + "size=m", + "tags=a", + "pick=2", + "fruits=apple", + "fruits=banana", + "token=secret", + ], + events: [ + "input text", + "change text", + "input count", + "change count", + "input agree=on", + "change agree=on", + "input size=m", + "change size=m", + "input tags=a", + "change tags=a", + "change tags=b", + "input pick", + "change pick", + "input fruits", + "change fruits", + ], + }); + // Unchanged values fire nothing, except that a checkbox or radio always fires change. + expect(outcome.repeated).toEqual([ + "change agree=on", + "change size=m", + "change tags=a", + "change tags=b", + ]); + expect(outcome.changed).toEqual({ + entries: [ + "text=123", + "count=456", + "size=m", + "tags=b", + "pick=2", + "fruits=apple", + "fruits=banana", + "token=secret", + ], + events: [ + "input agree=on", + "change agree=on", + "input tags=a", + "change tags=a", + "input tags=b", + "change tags=b", + ], + }); + expect(outcome.converted).toEqual([ + "text=123", + "count=1.2345678", + "agree=on", + "size=m", + "tags=b", + "pick=2", + "fruits=apple", + "fruits=banana", + "token=secret", + ]); +}); + +const invalidInputs = { + unknownParameter: { text: "changed", unknown: "value" }, + unknownOption: { text: "changed", pick: "c" }, + wordForCheckbox: { text: "changed", agree: "yes" }, + fractionForCheckbox: { text: "changed", agree: 0.5 }, + emptyNumber: { text: "changed", count: "" }, + wordForNumber: { text: "changed", count: "many" }, + stringForMultiple: { text: "changed", many: "x" }, + repeatedChoice: { text: "changed", many: ["x", "x"] }, + unknownCheckbox: { text: "changed", tags: ["c"] }, + stringForCheckboxes: { text: "changed", tags: "a" }, + wordForDate: { text: "changed", day: "tomorrow" }, + nullText: { text: null }, + arrayText: { text: ["changed"] }, +}; + +test("invalid input rejects before any control changes or the tool activates", async ({ + page, +}) => { + const outcome = await page.evaluate(async (inputs) => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+ + + + + + + +
`; + const events: string[] = []; + context.addEventListener("toolactivated", () => events.push("toolactivated")); + document.forms[0]!.addEventListener("input", () => events.push("input")); + const [tool] = await context.getTools(); + const errors: Record = {}; + for (const [label, input] of Object.entries(inputs)) { + errors[label] = await context.executeTool(tool!, input).catch((error) => error.name); + } + const text = (document.forms[0]!.elements.namedItem("text") as HTMLInputElement).value; + return { errors, events, text }; + }, invalidInputs); + + expect(outcome).toEqual({ + errors: Object.fromEntries(Object.keys(invalidInputs).map((label) => [label, "UnknownError"])), + events: [], + text: "original", + }); +}); + +test("month and week inputs accept only their formats in every browser", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+ +
`; + document.forms[0]!.addEventListener("submit", (event) => { + event.preventDefault(); + event.respondWith(Promise.resolve("filled")); + }); + const [tool] = await context.getTools(); + const results: Record = {}; + for (const [label, input] of Object.entries({ + month: { month: "2026-09" }, + monthName: { month: "September" }, + monthOutOfRange: { month: "2026-13" }, + week: { week: "2026-W38" }, + weekWithoutYear: { week: "W38" }, + week53: { week: "2020-W53" }, + week53OfShortYear: { week: "2025-W53" }, + })) { + results[label] = await context.executeTool(tool!, input).catch((error) => error.name); + } + return results; + }); + + expect(outcome).toEqual({ + month: "filled", + monthName: "UnknownError", + monthOutOfRange: "UnknownError", + week: "filled", + weekWithoutYear: "UnknownError", + week53: "filled", + week53OfShortYear: "UnknownError", + }); +}); + +test("the call settles with the page's response, null for a navigation, or an error", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` + +
+ +
`; + const form = document.forms[0]!; + const [tool] = await context.getTools(); + const respond = (event: SubmitEvent, response: Promise) => { + event.preventDefault(); + event.respondWith(response); + }; + const circular: Record = {}; + circular.self = circular; + const handlers: [string, (event: SubmitEvent) => void][] = [ + ["navigation", () => {}], + ["string", (event) => respond(event, Promise.resolve("plain"))], + ["object", (event) => respond(event, Promise.resolve({ ok: true }))], + ["number", (event) => respond(event, Promise.resolve(5))], + ["undefined", (event) => respond(event, Promise.resolve(undefined))], + [ + "after awaiting", + async (event) => { + event.preventDefault(); + await Promise.resolve(); + event.respondWith(Promise.resolve("responded late")); + }, + ], + ["circular", (event) => respond(event, Promise.resolve(circular))], + ["rejected", (event) => respond(event, Promise.reject(new Error("failed")))], + ["prevented", (event) => event.preventDefault()], + [ + "form.submit()", + (event) => { + event.preventDefault(); + form.submit(); + }, + ], + ]; + const results: Record = {}; + for (const [label, handler] of handlers) { + form.addEventListener("submit", handler, { once: true }); + results[label] = await context + .executeTool(tool!, { query: "value" }) + .catch((error) => error.name); + } + let submitted = false; + form.addEventListener("submit", () => (submitted = true)); + results.invalid = await context + .executeTool(tool!, { query: "" }) + .catch((error) => error.name); + return { results, submitted }; + }); + + expect(outcome).toEqual({ + results: { + navigation: null, + string: "plain", + object: '{"ok":true}', + number: "5", + undefined: "undefined", + "after awaiting": "responded late", + circular: "UnknownError", + rejected: "UnknownError", + prevented: "UnknownError", + "form.submit()": null, + invalid: "UnknownError", + }, + submitted: false, + }); +}); + +test("SubmitEvent gains agentInvoked and respondWith() with Chromium's checks", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const errorName = (operation: () => unknown): string => { + try { + operation(); + return "none"; + } catch (error) { + return error instanceof Error ? error.name : String(error); + } + }; + const prototype = SubmitEvent.prototype; + const agentInvoked = Object.getOwnPropertyDescriptor(prototype, "agentInvoked")!; + const respondWith = Object.getOwnPropertyDescriptor(prototype, "respondWith")!; + const shape = { + getter: { + enumerable: agentInvoked.enumerable, + configurable: agentInvoked.configurable, + setter: agentInvoked.set, + }, + method: { + enumerable: respondWith.enumerable, + writable: respondWith.writable, + length: respondWith.value.length, + }, + wrongBrand: { + getter: errorName(() => agentInvoked.get!.call(new Event("submit"))), + method: errorName(() => respondWith.value.call(new Event("submit"), Promise.resolve())), + }, + }; + + document.body.innerHTML = ` +
+
`; + const [agentForm, pageForm] = document.forms; + const pageSubmission: Record = {}; + pageForm!.addEventListener("submit", (event) => { + pageSubmission.agentInvoked = event.agentInvoked; + pageSubmission.respondWith = errorName(() => event.respondWith(Promise.resolve())); + event.preventDefault(); + }); + pageForm!.requestSubmit(); + + let saved: SubmitEvent | undefined; + const agentSubmission: Record = {}; + agentForm!.addEventListener("submit", (event) => { + saved = event; + agentSubmission.agentInvoked = event.agentInvoked; + agentSubmission.beforePreventDefault = errorName(() => event.respondWith(Promise.resolve())); + event.preventDefault(); + agentSubmission.withoutArguments = errorName(() => + Reflect.apply(respondWith.value, event, []), + ); + // Web IDL converts any value to a promise; the last response wins. + respondWith.value.call(event, "first"); + respondWith.value.call(event, "last"); + }); + const context = document.modelContext!; + const [tool] = await context.getTools(); + const result = await context.executeTool(tool!, {}); + const late = errorName(() => saved!.respondWith(Promise.resolve("late"))); + return { shape, pageSubmission, agentSubmission, result, late }; + }); + + expect(outcome).toEqual({ + shape: { + getter: { enumerable: true, configurable: true, setter: undefined }, + method: { enumerable: true, writable: true, length: 1 }, + wrongBrand: { getter: "TypeError", method: "TypeError" }, + }, + pageSubmission: { agentInvoked: false, respondWith: "InvalidStateError" }, + agentSubmission: { + agentInvoked: true, + beforePreventDefault: "InvalidStateError", + withoutArguments: "TypeError", + }, + result: "last", + late: "InvalidStateError", + }); +}); + +test("respondWith() throws once the agent's submission settles, even without a response", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const respond = (event: SubmitEvent): string => { + try { + event.respondWith(Promise.resolve("late")); + return "none"; + } catch (error) { + return error instanceof Error ? error.name : String(error); + } + }; + document.body.innerHTML = ` +
`; + const form = document.forms[0]!; + const context = document.modelContext!; + const [tool] = await context.getTools(); + const submit = async (listener: (event: SubmitEvent) => void) => { + let saved: SubmitEvent | undefined; + form.addEventListener( + "submit", + (event) => { + saved = event; + listener(event); + }, + { once: true }, + ); + const result = await context.executeTool(tool!, {}).catch((error) => error.name); + return { result, agentInvoked: saved!.agentInvoked, late: respond(saved!) }; + }; + + const prevented = await submit((event) => event.preventDefault()); + // As in Chromium, a response after a reset cancels the call is accepted and ignored. + let afterReset = ""; + const reset = await submit((event) => { + event.preventDefault(); + form.reset(); + afterReset = respond(event); + }); + return { prevented, reset, afterReset }; + }); + + const settled = { result: "UnknownError", agentInvoked: true, late: "InvalidStateError" }; + expect(outcome).toEqual({ prevented: settled, reset: settled, afterReset: "none" }); +}); + +test("capture listeners added before or after installation see the agent's submission", async ({ + page, +}) => { + await page.goto("/"); + const outcome = await page.evaluate(async () => { + const seen: string[] = []; + // Runs before the polyfill's listener, and responds before reading agentInvoked. + addEventListener( + "submit", + (event) => { + event.preventDefault(); + event.respondWith(Promise.resolve("responded")); + seen.push(`before ${event.agentInvoked}`); + }, + true, + ); + const script = document.createElement("script"); + script.textContent = await (await fetch("/auto.js")).text(); + document.head.append(script); + addEventListener("submit", (event) => seen.push(`after ${event.agentInvoked}`), true); + document.body.innerHTML = ` +
`; + const context = document.modelContext!; + const [tool] = await context.getTools(); + return { result: await context.executeTool(tool!, {}), seen }; + }); + + expect(outcome).toEqual({ result: "responded", seen: ["before true", "after true"] }); +}); + +test("without autosubmit, the call waits for the user to submit with the focused button", async ({ + page, +}) => { + const call = await page.evaluateHandle(async () => { + document.body.innerHTML = ` +
+ +
`; + const form = document.forms[0]!; + const input = form.elements[0] as HTMLInputElement; + const context = document.modelContext!; + let activated = ""; + context.addEventListener("toolactivated", () => { + activated = `${document.activeElement?.localName} ${input.value}`; + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + event.respondWith(Promise.resolve(`sent ${event.agentInvoked}`)); + }); + const [tool] = await context.getTools(); + return { result: context.executeTool(tool!, { note: "hello" }), activated: () => activated }; + }); + + await expect(page.getByRole("button")).toBeFocused(); + await expect(page.locator("input")).toHaveValue("hello"); + // The form is filled, and not yet focused, when toolactivated fires. + expect(await call.evaluate(({ activated }) => activated())).toBe("body hello"); + await page.getByRole("button").click(); + expect(await call.evaluate(({ result }) => result)).toBe("sent true"); +}); + +test("without autosubmit or a submit button, the call rejects without filling the form", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
`; + const [tool] = await context.getTools(); + const result = await context.executeTool(tool!, { a: "1" }).catch((error) => error.name); + return { result, value: (document.forms[0]!.elements[0] as HTMLInputElement).value }; + }); + + expect(outcome).toEqual({ result: "UnknownError", value: "" }); +}); + +test("the first enabled submit button in tree order submits, even from outside the form", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` + +
+ +
+ `; + const form = document.forms[0]!; + form.addEventListener("submit", (event) => { + event.preventDefault(); + event.respondWith(Promise.resolve((event.submitter as HTMLButtonElement).value)); + }); + const [tool] = await context.getTools(); + const outside = await context.executeTool(tool!, { item: "tea", note: "hot" }); + document.querySelector("button")!.disabled = true; + const inside = await context.executeTool(tool!, { item: "tea" }); + return { + properties: Object.keys((tool!.inputSchema as { properties: object }).properties), + outside, + inside, + }; + }); + + expect(outcome).toEqual({ properties: ["item", "note"], outside: "outside", inside: "inside" }); +}); + +test("the submit button is chosen after listeners react to the fill", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+ +
`; + const form = document.forms[0]!; + form.addEventListener("input", () => { + const fresh = document.createElement("button"); + fresh.name = "button"; + fresh.value = "new"; + form.querySelector("button")!.replaceWith(fresh); + }); + const submissions: string[] = []; + form.addEventListener("submit", (event) => { + event.preventDefault(); + const submitter = event.submitter as HTMLButtonElement; + submissions.push(`${event.agentInvoked} ${submitter.value}`); + if (event.agentInvoked) { + event.respondWith(Promise.resolve(submitter.value)); + } + }); + const [tool] = await context.getTools(); + const result = await context.executeTool(tool!, { a: "1" }).catch((error) => error.name); + form.requestSubmit(form.querySelector("button")); + return { result, submissions }; + }); + + expect(outcome).toEqual({ result: "new", submissions: ["true new", "false new"] }); +}); + +test("a toolactivated listener that submits the form itself completes the call once", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
`; + const form = document.forms[0]!; + let submissions = 0; + form.addEventListener("submit", (event) => { + submissions++; + event.preventDefault(); + const value = (form.elements[0] as HTMLInputElement).value; + event.respondWith(Promise.resolve(`submitted ${value}`)); + }); + context.addEventListener("toolactivated", () => form.requestSubmit()); + const [tool] = await context.getTools(); + const result = await context.executeTool(tool!, { a: "1" }); + return { result, submissions }; + }); + + expect(outcome).toEqual({ result: "submitted 1", submissions: 1 }); +}); + +test("a reset, removal or new declaration cancels a waiting call; a new schema does not", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+
+
+
+
`; + const tools = new Map((await context.getTools()).map((tool) => [tool.name, tool])); + const form = (name: string) => + document.querySelector(`form[toolname="${name}"]`)!; + const cancels: string[] = []; + context.addEventListener("toolcancel", (event) => cancels.push(event.toolName)); + // The call is wrapped so that awaiting the start does not await the call. + const start = async (name: string) => { + const activated = new Promise((resolve) => { + context.addEventListener("toolactivated", () => resolve(), { once: true }); + }); + const call = context.executeTool(tools.get(name)!, { a: "1" }).then( + (value) => `resolved ${value}`, + (error) => error.name, + ); + await Promise.race([activated, call]); + return { call }; + }; + + const reset = await start("reset"); + form("reset").reset(); + + const removed = await start("remove"); + form("remove").remove(); + + const rename = await start("rename"); + form("rename").setAttribute("tooldescription", "Renamed"); + + const kept = await start("kept"); + form("kept").addEventListener("reset", (event) => event.preventDefault()); + form("kept").reset(); + form("kept").addEventListener("submit", (event) => { + event.preventDefault(); + event.respondWith(Promise.resolve("kept")); + }); + form("kept").requestSubmit(); + + // Chromium also cancels a call when the form's controls change. + const grow = await start("grow"); + form("grow").insertAdjacentHTML("beforeend", ``); + const grown = (await context.getTools()).find((tool) => tool.name === "grow")!; + form("grow").addEventListener("submit", (event) => { + event.preventDefault(); + event.respondWith(Promise.resolve("grown")); + }); + form("grow").requestSubmit(); + + return { + reset: await reset.call, + removed: await removed.call, + rename: await rename.call, + kept: await kept.call, + grow: await grow.call, + properties: Object.keys((grown.inputSchema as { properties: object }).properties), + cancels, + }; + }); + + expect(outcome).toEqual({ + reset: "UnknownError", + removed: "UnknownError", + rename: "UnknownError", + kept: "resolved kept", + grow: "resolved grown", + properties: ["a", "b"], + cancels: [], + }); +}); + +test("a reset right before the page's own submission cancels the call first", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
`; + const form = document.forms[0]!; + let agentInvoked: boolean | undefined; + form.addEventListener("submit", (event) => { + event.preventDefault(); + agentInvoked = event.agentInvoked; + }); + const activated = new Promise((resolve) => { + context.addEventListener("toolactivated", () => resolve(), { once: true }); + }); + const [tool] = await context.getTools(); + const call = context.executeTool(tool!, { a: "1" }).catch((error) => error.name); + await activated; + form.reset(); + form.requestSubmit(); + return { call: await call, agentInvoked }; + }); + + expect(outcome).toEqual({ call: "UnknownError", agentInvoked: false }); +}); + +test("an earlier submission read while a call waits does not become the agent's", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
`; + const form = document.forms[0]!; + const events: SubmitEvent[] = []; + form.addEventListener("submit", (event) => { + event.preventDefault(); + events.push(event); + }); + form.requestSubmit(); + const activated = new Promise((resolve) => { + context.addEventListener("toolactivated", () => resolve(), { once: true }); + }); + const [tool] = await context.getTools(); + const call = context.executeTool(tool!, {}).catch((error) => error.name); + await activated; + const earlier = events[0]!.agentInvoked; + form.requestSubmit(); + return { earlier, agent: events[1]!.agentInvoked, call: await call }; + }); + + expect(outcome).toEqual({ earlier: false, agent: true, call: "UnknownError" }); +}); + +test("a form moved into a shadow tree does not submit for the agent", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+
`; + const form = document.forms[0]!; + let submission = ""; + form.addEventListener("submit", (event) => { + event.preventDefault(); + try { + event.respondWith(Promise.resolve("from shadow")); + submission = `${event.agentInvoked} responded`; + } catch (error) { + submission = `${event.agentInvoked} ${error instanceof Error ? error.name : error}`; + } + }); + const activated = new Promise((resolve) => { + context.addEventListener("toolactivated", () => resolve(), { once: true }); + }); + const [tool] = await context.getTools(); + const call = context.executeTool(tool!, {}).catch((error) => error.name); + await activated; + document.querySelector("div")!.attachShadow({ mode: "open" }).append(form); + form.requestSubmit(); + return { submission, call: await call }; + }); + + expect(outcome).toEqual({ submission: "false InvalidStateError", call: "UnknownError" }); +}); + +test("a newer call rejects one that waits for the user, but not one the page is handling", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+
`; + for (const form of document.forms) { + form.addEventListener("submit", (event) => { + event.preventDefault(); + event.respondWith(Promise.resolve((form.elements[0] as HTMLInputElement).value)); + }); + } + const [echo, review] = await context.getTools(); + const settle = (promise: Promise) => + promise.then( + (value) => `resolved ${value}`, + (error) => error.name, + ); + const activated = () => + new Promise((resolve) => { + context.addEventListener("toolactivated", () => resolve(), { once: true }); + }); + let waiting = activated(); + // Chromium leaves the older call pending instead. + const first = settle(context.executeTool(review!, { a: "1" })); + await waiting; + waiting = activated(); + const second = settle(context.executeTool(review!, { a: "2" })); + await waiting; + document.forms[1]!.requestSubmit(); + + // The second call fills the form before the first call's submission settles. + const echoes = await Promise.all(["1", "2"].map((a) => context.executeTool(echo!, { a }))); + return { first: await first, second: await second, echoes }; + }); + + expect(outcome).toEqual({ first: "UnknownError", second: "resolved 2", echoes: ["1", "2"] }); +}); + +test("a change during the agent's submit event keeps the call, a later one cancels it", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+
+
`; + const tools = new Map((await context.getTools()).map((tool) => [tool.name, tool])); + const form = (name: string) => + document.querySelector(`form[toolname="${name}"]`)!; + const cancels: string[] = []; + context.addEventListener("toolcancel", (event) => cancels.push(event.toolName)); + const settle = (promise: Promise) => + promise.then( + (value) => `resolved ${value}`, + (error) => error.name, + ); + const respondLater = async (name: string) => { + const { promise: response, resolve: respond } = Promise.withResolvers(); + const submitted = new Promise((resolve) => { + form(name).addEventListener( + "submit", + (event) => { + event.preventDefault(); + event.respondWith(response); + resolve(); + }, + { once: true }, + ); + }); + const call = settle(context.executeTool(tools.get(name)!)); + await submitted; + return { call, respond: () => respond("late") }; + }; + + form("inline").addEventListener("submit", (event) => { + event.preventDefault(); + event.respondWith(Promise.resolve("kept")); + form("inline").remove(); + }); + const inline = settle(context.executeTool(tools.get("inline")!)); + + // A reset or removal cancels every call that waits for a response, not only the latest. + const older = await respondLater("reset"); + const newer = await respondLater("reset"); + // Discovery resolves after the polyfill has handled the submissions. + await context.getTools(); + form("reset").reset(); + older.respond(); + newer.respond(); + + const removed = [await respondLater("remove"), await respondLater("remove")]; + await context.getTools(); + form("remove").remove(); + await context.getTools(); + removed.forEach((call) => call.respond()); + + return { + inline: await inline, + reset: [await older.call, await newer.call], + removed: [await removed[0]!.call, await removed[1]!.call], + cancels, + tools: (await context.getTools()).map((tool) => tool.name), + }; + }); + + expect(outcome).toEqual({ + inline: "resolved kept", + reset: ["UnknownError", "UnknownError"], + removed: ["UnknownError", "UnknownError"], + cancels: [], + tools: ["reset"], + }); +}); + +test("aborting a declarative call fires toolcancel and releases its form", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + document.body.innerHTML = ` +
+
`; + const [reviewForm, slowForm] = document.forms; + const events: string[] = []; + for (const type of ["toolactivated", "toolcancel"] as const) { + context.addEventListener(type, (event) => events.push(`${type} ${event.toolName}`)); + } + const [review, slow] = await context.getTools(); + const abortAfter = async ( + tool: WebMCP.RegisteredTool, + input: object, + started: Promise, + ) => { + const controller = new AbortController(); + const call = context + .executeTool(tool, input, { signal: controller.signal }) + .catch((reason) => events.push(`rejected ${reason}`)); + await started; + controller.abort("stop"); + await call; + // Discovery resolves after the queued cancellation that fires toolcancel. + await context.getTools(); + }; + + const activated = new Promise((resolve) => { + context.addEventListener("toolactivated", resolve, { once: true }); + }); + await abortAfter(review!, { a: "1" }, activated); + let agentInvoked: boolean | undefined; + reviewForm!.addEventListener("submit", (event) => { + agentInvoked = event.agentInvoked; + event.preventDefault(); + }); + reviewForm!.requestSubmit(); + + const responding = new Promise((resolve) => { + slowForm!.addEventListener("submit", (event) => { + event.preventDefault(); + event.respondWith(new Promise(() => {})); + resolve(); + }); + }); + await abortAfter(slow!, {}, responding); + return { events, agentInvoked }; + }); + + expect(outcome).toEqual({ + events: [ + "toolactivated review", + "rejected stop", + "toolcancel review", + "toolactivated slow", + "rejected stop", + "toolcancel slow", + ], + agentInvoked: false, + }); +}); diff --git a/tests/draft-members.d.ts b/tests/draft-members.d.ts new file mode 100644 index 0000000..18031c1 --- /dev/null +++ b/tests/draft-members.d.ts @@ -0,0 +1,9 @@ +// SubmitEvent members come from the declarative explainer and have no upstream types yet. +export {}; + +declare global { + interface SubmitEvent { + readonly agentInvoked: boolean; + respondWith(agentResponse: PromiseLike): void; + } +} diff --git a/tests/fixtures/schemas.ts b/tests/fixtures/schemas.ts new file mode 100644 index 0000000..d83dbdf --- /dev/null +++ b/tests/fixtures/schemas.ts @@ -0,0 +1,239 @@ +// Chromium's html_form_mcp_tool_test.cc schema cases at dbdbb13fd74c, checked in Chrome Canary +// 156.0.8069.0, leaving out the flagged file input and form-associated custom element cases. +export const chromiumSchemas: { name: string; html: string; schema: object }[] = [ + { + name: "ParameterSchema_Disabled", + html: `