From 74ccd8d97569aa3465ca37353c635591cd9169bd Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Mon, 14 Sep 2026 08:10:30 -0700 Subject: [PATCH 1/9] feat(polyfill): add WebMCP registration and discovery Match the published official types with a document-local implementation and real-browser, package-consumer, and pinned WPT checks. Resolve package dependencies with fresh registry metadata so clean CI runners do not depend on a warm offline cache. Allow only the downloaded Chrome executable to create user namespaces on Ubuntu, keeping its sandbox enabled, and expose WPT startup logs. Co-Authored-By: Claude Opus 5 --- .github/workflows/test.yml | 90 ++++++ .gitignore | 9 + .oxlintrc.json | 10 + AGENTS.md | 42 +++ LICENSE | 21 ++ README.md | 64 ++++ TESTING.md | 116 +++++++ app.test.ts | 27 ++ auto.ts | 4 + fixtures/app.js | 34 ++ fixtures/server.mjs | 36 +++ index.test-d.ts | 21 ++ index.test.ts | 625 +++++++++++++++++++++++++++++++++++++ index.ts | 355 +++++++++++++++++++++ native.test.ts | 35 +++ package.json | 45 +++ package.test.mjs | 100 ++++++ playwright.config.ts | 34 ++ pnpm-lock.yaml | 556 +++++++++++++++++++++++++++++++++ tsconfig.json | 14 + tsconfig.test.json | 11 + wpt.mjs | 108 +++++++ 22 files changed, 2357 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .oxlintrc.json create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 TESTING.md create mode 100644 app.test.ts create mode 100644 auto.ts create mode 100644 fixtures/app.js create mode 100644 fixtures/server.mjs create mode 100644 index.test-d.ts create mode 100644 index.test.ts create mode 100644 index.ts create mode 100644 native.test.ts create mode 100644 package.json create mode 100644 package.test.mjs create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 tsconfig.json create mode 100644 tsconfig.test.json create mode 100644 wpt.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b8e40d4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,90 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 24 + - run: npm install --global pnpm@10.14.0 + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium firefox webkit + - run: pnpm test + - run: pnpm test:package + - uses: actions/upload-artifact@v7 + if: always() + with: + name: browser-tests + path: playwright-report/ + retention-days: 7 + + wpt: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/checkout@v7 + with: + repository: web-platform-tests/wpt + ref: 1a21db90adf8a264370ad806ed761f39e1d435a0 + path: .reference/wpt + persist-credentials: false + sparse-checkout: | + common + docs + interfaces + resources + tools + webmcp + - uses: actions/setup-node@v7 + with: + node-version: 24 + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - run: npm install --global pnpm@10.14.0 + - run: pnpm install --frozen-lockfile + - uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd + id: chrome + with: + chrome-version: canary + - name: Allow Chrome to create its sandbox + env: + CHROME_BIN: ${{ steps.chrome.outputs.chrome-path }} + run: | + sudo tee /etc/apparmor.d/webmcp-chrome >/dev/null <, + profile webmcp-chrome "$CHROME_BIN" flags=(unconfined) { + userns, + } + EOF + sudo apparmor_parser -r /etc/apparmor.d/webmcp-chrome + - run: pnpm test:wpt + env: + WPT_ROOT: .reference/wpt + CHROME_BIN: ${{ steps.chrome.outputs.chrome-path }} + - uses: actions/upload-artifact@v7 + if: always() + with: + name: wpt-results + path: wpt-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f42d5e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +test-results/ +playwright-report/ +*.tgz +wpt-results/ +wpt-metadata/MANIFEST.json +wpt-metadata/.cache/ +.reference/ diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..dcf14a1 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "plugins": ["typescript", "unicorn"], + "categories": { + "correctness": "error" + }, + "rules": { + "typescript/no-explicit-any": "error", + "eslint/no-shadow": "error" + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..41ea58d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# Working on the polyfill + +This package implements document-local WebMCP registration and discovery. Use +the official `webmcp-types` dependency without duplicating or augmenting its +declarations. Execution is deferred to a separate change alongside types PR #3. +No MCP server, transport, extension product, navigator aliases, or legacy API +compatibility belongs here. + +## Before changing behavior + +1. Read the live [Community Group draft](https://webmachinelearning.github.io/webmcp/) + and the diff from the source revision recorded in TESTING.md. +2. Read the relevant upstream WPT, including its helpers and IDL. The pin and + exact selection are in `wpt.mjs`; known disagreements are in TESTING.md. +3. Check browser evidence using the source map in TESTING.md. A Chromium test, + browser issue, or standards-position discussion alone is not the specification. +4. Add the smallest real-browser regression that demonstrates the behavior. + Tests load the built bundle from a real server; do not replace DOM APIs, + page requests, or tool callbacks with mocks. + +## Validate + +Run `pnpm test`, `pnpm test:package`, and `pnpm test:wpt`. TESTING.md has the +prerequisites and says what each suite covers. Type checking runs over the test +code and the published declarations, not just `index.ts`. A missing browser or +driver must fail its project, never become a skipped test. Do not count an +excluded test as a pass, and do not add compatibility behavior only to satisfy +one. + +When moving the WPT pin, read the upstream diff first, update both `wpt.mjs` and +the CI pin, and inspect every changed assertion and expectation. Do not +regenerate failure metadata without reading each one. In the pull request +description, record the draft and WPT revisions, browser versions, passes, +expected failures, and excluded behavior separately. Keep upstream tests +unmodified and do not claim full conformance. + +## Code + +Web IDL conversion accepts unknown values and broad objects, and feature +detection needs runtime checks. Do not narrow either one to satisfy a lint rule. +Keep casts at conversion boundaries, state the invariant each one checks, and use +the upstream types and concrete return types inside the implementation. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fed8fa1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 WebMCP polyfill contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b13432b --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# WebMCP polyfill + +A polyfill for [WebMCP](https://webmachinelearning.github.io/webmcp/), with types from [webmcp-types](https://github.com/webmachinelearning/webmcp-types) and no runtime JavaScript dependencies. + +## Build + +This package is under review. To try it locally, build this checkout with Node.js 24 and pnpm: + +```sh +pnpm install +pnpm build +``` + +Then install the checkout in your app with `pnpm add /path/to/webmcp-polyfill`. For a classic script, serve the built `dist/polyfill.js`. + +## Usage + +Serve your page over HTTPS with the `Origin-Agent-Cluster: ?1` response header. Localhost HTTP also works for development. + +Load the polyfill before registering tools: + +```ts +import "webmcp-polyfill/auto"; + +const context = document.modelContext; +if (!context) throw new Error("WebMCP requires a secure browser context"); + +const registration = new AbortController(); +await context.registerTool( + { + name: "page-title", + description: "Get the title of this page", + execute() { + return { title: document.title }; + }, + }, + { signal: registration.signal }, +); + +console.log(await context.getTools()); + +// Remove the tool when it is no longer needed. +registration.abort(); +``` + +To install explicitly, import and call `installWebMCP` from `webmcp-polyfill`. Installation requires a secure browser context and leaves an existing `document.modelContext` unchanged, including partial native implementations. It affects only the realm that calls it, so each frame installs separately. + +## Scope + +Tools stay in the current document. Cross-document tools, declarative forms, lifecycle window events, and browser agent integration aren't implemented. Nonempty `exposedTo` and `fromOrigins` options reject. + +This version supports registration, discovery, and `toolchange`. It does not invoke registered callbacks; `executeTool()` follows alongside [the types update](https://github.com/webmachinelearning/webmcp-types/pull/3). + +The implementation follows [draft source `cc45efc`](https://github.com/webmachinelearning/webmcp/blob/cc45efcaf0/index.bs) and passes all 27 selected assertions of the [upstream WPT](https://github.com/web-platform-tests/wpt/tree/1a21db90adf8a264370ad806ed761f39e1d435a0/webmcp) at the pin, with no expected failures. [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) records the selection and what it excludes. + +The API tracks the draft. Breaking changes ship with notes: in minor releases while the version is 0.x, in majors after 1.0. + +## Development + +See [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) for browser setup, test commands, draft alignment, and known limitations. + +## License + +[MIT](LICENSE). diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..e4a2a4e --- /dev/null +++ b/TESTING.md @@ -0,0 +1,116 @@ +# Testing and upstream tracking + +## Run the browser suite + +Use Node.js 24, pnpm, and the Playwright browsers pinned by the lockfile: + +```sh +pnpm install --frozen-lockfile +pnpm exec playwright install --with-deps chromium firefox webkit +pnpm test +pnpm test:package +``` + +| Check | What it runs | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index.test-d.ts` | TypeScript against the built package declarations, including upstream schema inference | +| `index.test.ts` | Built bundle served over HTTP in Chromium (native WebMCP disabled), Firefox, and WebKit; coercion, metadata, events, registration abort, errors, detached documents | +| `app.test.ts` | A served application, real button interactions, discovery, unregistration, and reload | +| `native.test.ts` | Real native Chromium registration, then polyfill loading; context and getter identities must survive | +| `pnpm test:package` | Packed tarball installed into a fresh consumer, public type imports, SSR-safe entry points, and package contents | +| `pnpm test:wpt` | Unmodified upstream registration/discovery WPT in real Chrome Canary, with native WebMCP disabled | + +The fixture server binds 127.0.0.1:8793 and sets the required `Origin-Agent-Cluster` +header; Playwright never reuses an existing server, so free that port first. +Loopback HTTP is a secure context; +the WPT `non-secure.html` case supplies the nonsecure-origin check. The browser +suite uses fresh contexts and real network responses, without route interception, +fake timers, DOM shims, or mocked tool callbacks. + +Playwright retains failure traces and an HTML report in `playwright-report/`. +Playwright drives its bundled Firefox for page tests. WebKit is additional engine +coverage, not a claim that Safari itself was tested. + +## Run upstream WPT + +Use Python 3.11+, Chrome Canary, and a WPT checkout at +`1a21db90adf8a264370ad806ed761f39e1d435a0`. A sparse checkout needs `common`, +`docs`, `interfaces`, `resources`, `tools`, and `webmcp` plus the root files. +The CI workflow contains the exact checkout recipe. On Ubuntu, CI installs an +[AppArmor profile](https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md) +for the downloaded Chrome binary so it can create its sandbox using user +namespaces. The profile applies only to that executable. + +```sh +WPT_ROOT=../wpt CHROME_BIN=/path/to/chrome-canary pnpm test:wpt +``` + +`WPT_PYTHON` and `WPT_VENV` optionally select the interpreter and environment. +The runner checks the revision, rejects tracked source changes, requires all 13 selected files +to run exactly once, checks assertion counts, and retains `wpt-results/report.json` +with browser and upstream revisions. Runner errors, unexpected failures, and +unexpected passes fail the command, and nothing is retried. + +At this pin, **all 27 selected assertions pass**, with no expected failures. + +### Excluded coverage + +Execution tests and the full IDL harness are deferred with `executeTool()`. +The IDL includes that method, so running the full shape suite against this +registration/discovery subset would intentionally fail. Keep that exclusion +explicit rather than adding expected failures for an API not yet included. + +Cross-document discovery, frame-tree routing, declarative forms, browser +permissions integration, CSS states, navigation/BFCache, and lifecycle window +events are also outside this initial implementation. + +WPT's `--inject-script` modifies testharness pages; it does not install the bundle +in `/common/blank.html` helper frames. Detached-frame WPT therefore cannot run +unchanged in this lane. The local test serves an instrumented iframe, removes +it, and checks registration, discovery, and exception realms. + +## Where to look when upstream changes + +| Source | Use | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| [Draft source and history](https://github.com/webmachinelearning/webmcp/commits/main/index.bs) | Normative algorithms, Web IDL, open issues; compare with the revision below | +| [WPT webmcp](https://github.com/web-platform-tests/wpt/tree/master/webmcp) and [results](https://wpt.fyi/results/webmcp) | Executable assertions and native cross-browser results; these results are not polyfill results | +| [WPT IDL](https://github.com/web-platform-tests/wpt/blob/master/interfaces/webmcp.idl) | Generated interface snapshot; it can lag the published draft | +| [Official types](https://github.com/webmachinelearning/webmcp-types) | Public declarations, schema inference, and pending API updates | +| [Blink script_tools](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/script_tools/) | Chromium IDL, implementation, tests, and commit-linked bugs | +| [Gecko source search](https://searchfox.org/mozilla-central/search?q=ModelContext) and [Mozilla position](https://github.com/mozilla/standards-positions/issues/1412) | Locate Firefox implementation work and discussion; a position is not evidence of shipped support | + +Reproduce a disagreement before changing code or expectations, and record what +changed in the draft, types, WPT, and browser implementation separately. + +## Lint + +`pnpm lint` runs Oxlint with correctness checks, shadowing checks, and a ban on +explicit `any`. Warnings fail the command, and `pnpm test` runs it before compiling. + +## Draft and types + +The implementation was compared with [draft source `cc45efc`](https://github.com/webmachinelearning/webmcp/blob/cc45efcaf0/index.bs). +It uses timers to queue tasks; JavaScript cannot reproduce the browser's WebMCP +task source, nor the draft's abort *algorithms*, which run before an abort event +rather than as a listener. + +The runtime checks the `tools` Permissions Policy when the browser exposes it. +No engine lists `tools` in `permissionsPolicy.features()` today, so that branch +is currently inert and cross-origin frames are denied by the same-origin +fallback, which stands in for the feature's `self` default allowlist: it denies a +cross-origin frame the embedder allowed, and allows a same-origin frame the +embedder denied. Operations reject with `SecurityError` when `originAgentCluster` +is false, except in `file:` documents; where the property is absent the check is +skipped. Schema inference does not validate callback inputs at runtime. + +Non-empty `exposedTo` and `fromOrigins` reject with `NotSupportedError`. The +draft would validate the origins and then resolve, since a document-local +implementation has nowhere to expose a tool to; rejecting keeps the polyfill from +implying cross-document support it does not have. Origins are validated first, so +an untrustworthy one still fails with the `SecurityError` the draft requires. + +To test types changes, keep `webmcp-types` beside this checkout and run +`pnpm link ../webmcp-types`, then `pnpm typecheck`. Restore the published +dependency with `pnpm install --force` before package checks. Do not commit +local dependency overrides. diff --git a/app.test.ts b/app.test.ts new file mode 100644 index 0000000..7e61787 --- /dev/null +++ b/app.test.ts @@ -0,0 +1,27 @@ +import { test, expect } from "@playwright/test"; + +test("a served application registers, discovers, unregisters, and resets on reload", async ({ + page, + browser, +}, testInfo) => { + await testInfo.attach("browser.json", { + body: JSON.stringify({ project: testInfo.project.name, version: browser.version() }), + contentType: "application/json", + }); + const errors: Error[] = []; + page.on("pageerror", (error) => errors.push(error)); + await page.goto("/app"); + await expect(page.locator("#status")).toHaveText("registered"); + expect( + await page.evaluate(async () => + (await document.modelContext!.getTools()).map((tool) => tool.name), + ), + ).toEqual(["increment"]); + await page.getByRole("button", { name: "Unregister", exact: true }).click(); + await expect(page.locator("#status")).toHaveText("unregistered"); + expect(await page.evaluate(() => document.modelContext!.getTools())).toEqual([]); + await page.reload(); + await expect(page.locator("#status")).toHaveText("registered"); + await expect(page.locator("#count")).toHaveText("0"); + expect(errors).toEqual([]); +}); diff --git a/auto.ts b/auto.ts new file mode 100644 index 0000000..43e63ce --- /dev/null +++ b/auto.ts @@ -0,0 +1,4 @@ +import { installWebMCP } from "./index.js"; +export type { WebMCP } from "./index.js"; + +installWebMCP(); diff --git a/fixtures/app.js b/fixtures/app.js new file mode 100644 index 0000000..c078bb1 --- /dev/null +++ b/fixtures/app.js @@ -0,0 +1,34 @@ +const context = document.modelContext; +const element = (id) => document.getElementById(id); +let registration; +let count = 0; +async function register() { + registration = new AbortController(); + await context.registerTool( + { + name: "increment", + description: "Increment the visible counter", + inputSchema: { + type: "object", + properties: { amount: { type: "number" } }, + required: ["amount"], + }, + execute({ amount }) { + if (!Number.isFinite(amount) || amount < 0) + throw new TypeError("Expected a nonnegative amount"); + count += amount; + element("count").textContent = String(count); + return { count }; + }, + }, + { signal: registration.signal }, + ); + element("status").textContent = "registered"; +} +element("register").onclick = register; +element("unregister").onclick = async () => { + registration.abort(); + await context.getTools(); + element("status").textContent = "unregistered"; +}; +await register(); diff --git a/fixtures/server.mjs b/fixtures/server.mjs new file mode 100644 index 0000000..18e5252 --- /dev/null +++ b/fixtures/server.mjs @@ -0,0 +1,36 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; + +const app = `WebMCP counter +

Counter

0 + +loading`; + +createServer(async (request, response) => { + const path = new URL(request.url, "http://localhost").pathname; + response.setHeader("Origin-Agent-Cluster", path === "/no-cluster" ? "?0" : "?1"); + response.setHeader("Cache-Control", "no-store"); + try { + if (path === "/auto.js" || path === "/app.js") { + response.setHeader("Content-Type", "text/javascript"); + response.end( + await readFile( + new URL(path === "/auto.js" ? "../dist/polyfill.js" : "./app.js", import.meta.url), + ), + ); + } else if (["/", "/health", "/no-cluster"].includes(path)) { + response.setHeader("Content-Type", "text/html"); + response.end("WebMCP test"); + } else if (path === "/app") { + response.setHeader("Content-Type", "text/html"); + response.end( + app + '', + ); + } else { + response.writeHead(404).end(); + } + } catch (error) { + console.error(error); + response.writeHead(500).end(); + } +}).listen(8793, "127.0.0.1"); diff --git a/index.test-d.ts b/index.test-d.ts new file mode 100644 index 0000000..af01976 --- /dev/null +++ b/index.test-d.ts @@ -0,0 +1,21 @@ +import "./dist/auto.js"; + +if (document.modelContext) { + const context: WebMCP.ModelContext = document.modelContext; + context.registerTool({ + name: "greet", + description: "Greet someone", + inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + execute(input, { signal }) { + const name: string = input.name; + const aborted: boolean = signal.aborted; + // @ts-expect-error Upstream inference keeps name a string. + const invalid: number = input.name; + return { name, aborted, invalid }; + }, + }); + const tools: WebMCP.RegisteredTool[] = await context.getTools(); + // @ts-expect-error Execution is deferred to a separate change. + void context.executeTool; + void tools; +} diff --git a/index.test.ts b/index.test.ts new file mode 100644 index 0000000..875573f --- /dev/null +++ b/index.test.ts @@ -0,0 +1,625 @@ +import { test, expect } from "@playwright/test"; +import "./index.js"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + expect(await page.evaluate(() => "modelContext" in document)).toBe(false); +}); + +test("every operation rejects when the server opts out of origin-keyed agent clustering", async ({ + page, +}) => { + await page.goto("http://127.0.0.1:8793/no-cluster"); + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + const errors = []; + for (const operation of [ + () => context.getTools(), + () => context.registerTool({ name: "x", description: "X", execute: () => null }), + ]) { + try { + await operation(); + errors.push("resolved"); + } catch (error) { + if (!(error instanceof Error)) throw error; + errors.push(error.name); + } + } + return errors; + }), + ).toEqual(["SecurityError", "SecurityError"]); +}); + +test("only potentially trustworthy origins reach the cross-document refusal", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + // NotSupportedError means the origin was accepted as potentially trustworthy and then refused + // because cross-document exposure needs native WebMCP; SecurityError means it was rejected. + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + const results: [string, string][] = []; + for (const origin of [ + "https://example.test", + "wss://example.test", + "file:///tmp", + "http://127.0.0.1:8793", + "http://[::1]:8793", + "http://localhost:8793", + "http://localhost.:8793", + "http://app.localhost:8793", + "ws://localhost:8793", + "http://example.test", + "ws://example.test", + "ftp://localhost", + "http://127.example.test", + "not a url", + ]) { + try { + await context.getTools({ fromOrigins: [origin] }); + results.push([origin, "resolved"]); + } catch (error) { + if (!(error instanceof Error)) throw error; + results.push([origin, error.name]); + } + } + return results; + }), + ).toEqual([ + ["https://example.test", "NotSupportedError"], + ["wss://example.test", "NotSupportedError"], + ["file:///tmp", "NotSupportedError"], + ["http://127.0.0.1:8793", "NotSupportedError"], + ["http://[::1]:8793", "NotSupportedError"], + ["http://localhost:8793", "NotSupportedError"], + ["http://localhost.:8793", "NotSupportedError"], + ["http://app.localhost:8793", "NotSupportedError"], + ["ws://localhost:8793", "NotSupportedError"], + ["http://example.test", "SecurityError"], + ["ws://example.test", "SecurityError"], + ["ftp://localhost", "SecurityError"], + ["http://127.example.test", "SecurityError"], + ["not a url", "SecurityError"], + ]); +}); + +test("a cross-origin frame is denied without a native Permissions Policy", async ({ page }) => { + await page.evaluate( + () => + new Promise((resolve) => { + const iframe = document.createElement("iframe"); + iframe.src = "http://127.0.0.1:8793/"; + iframe.onload = () => resolve(); + document.body.append(iframe); + }), + ); + const frame = page.frames().find((candidate) => candidate.url() === "http://127.0.0.1:8793/"); + expect(frame, "the cross-origin child frame must be attached").toBeTruthy(); + await frame!.addScriptTag({ url: "http://127.0.0.1:8793/auto.js" }); + expect( + await frame!.evaluate(async () => { + try { + await document.modelContext!.getTools(); + return "resolved"; + } catch (error) { + if (!(error instanceof Error)) throw error; + return error.name; + } + }), + ).toBe("NotAllowedError"); +}); + +test("a signal option that is not an AbortSignal is rejected", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + const results: [string, string][] = []; + const tool = { name: "x", description: "X", execute: () => null }; + for (const signal of [1, {}, null, "abort"] as unknown[]) { + const label = JSON.stringify(signal)!; + try { + // @ts-expect-error Exercise invalid JavaScript callers at the Web IDL boundary. + await context.registerTool(tool, { signal }); + results.push([label, "resolved"]); + } catch (error) { + if (!(error instanceof Error)) throw error; + results.push([label, error.name]); + } + } + return results; + }), + ).toEqual([ + ["1", "TypeError"], + ["{}", "TypeError"], + ["null", "TypeError"], + ['"abort"', "TypeError"], + ]); +}); + +test("an already-aborted registration signal registers nothing and fires no toolchange", async ({ + page, +}) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + let changes = 0; + context.ontoolchange = () => changes++; + let reason: unknown = "resolved"; + try { + await context.registerTool( + { name: "x", description: "X", execute: () => null }, + { signal: AbortSignal.abort("already aborted") }, + ); + } catch (error) { + reason = error; + } + // getTools() resolves from a queued task, so awaiting it drains any pending toolchange. + return { reason, count: (await context.getTools()).length, changes }; + }), + ).toEqual({ reason: "already aborted", count: 0, changes: 0 }); +}); + +test("registration converts every dictionary member in Web IDL order", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const reads: string[] = []; + const record = (name: string, value: T): T => { + reads.push(name); + return value; + }; + await document.modelContext!.registerTool( + { + get annotations() { + return record("annotations", undefined); + }, + get description() { + return record("description", "D"); + }, + get execute() { + return record("execute", () => null); + }, + get inputSchema() { + return record("inputSchema", undefined); + }, + get name() { + return record("name", "ordered"); + }, + get title() { + return record("title", "Ordered"); + }, + }, + { + get exposedTo() { + return record("exposedTo", undefined); + }, + get signal() { + return record("signal", undefined); + }, + }, + ); + return reads; + }), + ).toEqual([ + "annotations", + "description", + "execute", + "inputSchema", + "name", + "title", + "exposedTo", + "signal", + ]); +}); + +test("origin conversion gets an iterator only once and preserves its receiver", async ({ + page, +}) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + let reads = 0; + let receiver = false; + const origins = { + get [Symbol.iterator]() { + if (++reads > 1) throw new Error("Iterator getter was read twice"); + return function (this: typeof origins) { + receiver = this === origins; + return [][Symbol.iterator](); + }; + }, + }; + // @ts-expect-error Web IDL accepts iterables; the published types use arrays. + await document.modelContext!.getTools({ fromOrigins: origins }); + return { reads, receiver }; + }), + ).toEqual({ reads: 1, receiver: true }); +}); + +test("operations on a real detached frame reject in the frame's realm", async ({ page }) => { + // The /app iframe installs the polyfill; this document deliberately does not. + const frame = await page.evaluate(async () => { + const iframe = document.createElement("iframe"); + iframe.src = "/app"; + const loaded = new Promise((resolve) => { + iframe.onload = () => resolve(); + }); + document.body.append(iframe); + await loaded; + const context = iframe.contentDocument!.modelContext!; + const FrameException = iframe.contentDocument!.defaultView!.DOMException; + iframe.remove(); + const errors = []; + for (const operation of [ + () => context.getTools(), + () => + context.registerTool({ name: "detached", description: "Detached", execute: () => null }), + ]) { + try { + await operation(); + errors.push("resolved"); + } catch (error) { + errors.push(error instanceof FrameException ? error.name : "wrong realm"); + } + } + return errors; + }); + expect(frame).toEqual(["InvalidStateError", "InvalidStateError"]); +}); + +test("installs once, exposes only standard members, and keeps document identity", async ({ + page, +}) => { + await page.addScriptTag({ url: "/auto.js" }); + const initial = await page.evaluateHandle(() => document.modelContext); + const result = await page.evaluate(() => { + const context = document.modelContext!; + const constructor = "ModelContext" in window ? window.ModelContext : undefined; + if (typeof constructor !== "function") throw new Error("ModelContext constructor is missing"); + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!; + let constructionError = ""; + try { + Reflect.construct(constructor, []); + } catch (error) { + if (!(error instanceof Error)) throw error; + constructionError = error.name; + } + const getterErrors = []; + for (const receiver of [{}, Object.create(Document.prototype), null, undefined, 1]) { + try { + descriptor.get!.call(receiver); + getterErrors.push("resolved"); + } catch (error) { + if (!(error instanceof Error)) throw error; + getterErrors.push(error.name); + } + } + return { + same: context === document.modelContext, + members: Object.keys(Object.getPrototypeOf(context)).sort(), + own: Object.keys(context), + brand: Object.prototype.toString.call(context), + instance: context instanceof constructor && context instanceof EventTarget, + constructorParent: Object.getPrototypeOf(constructor) === EventTarget, + constructorName: constructor.name, + writable: descriptor.set !== undefined, + alias: "modelContext" in navigator, + testing: "modelContextTesting" in navigator, + lengths: [context.registerTool.length, context.getTools.length], + constructionError, + getterErrors, + }; + }); + expect(result).toEqual({ + same: true, + members: ["getTools", "ontoolchange", "registerTool"], + own: [], + brand: "[object ModelContext]", + instance: true, + constructorParent: true, + constructorName: "ModelContext", + writable: false, + alias: false, + testing: false, + lengths: [1, 0], + constructionError: "TypeError", + getterErrors: ["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], + }); + const getter = await page.evaluateHandle( + () => Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!.get, + ); + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate( + ([previous, previousGetter]) => ({ + context: document.modelContext === previous, + getter: + Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!.get === + previousGetter, + }), + [initial, getter] as const, + ), + ).toEqual({ context: true, getter: true }); +}); + +test("snapshots registration metadata and returns sorted, independent copies", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const result = await page.evaluate(async () => { + const context = document.modelContext!; + const schema = { type: "object", properties: { query: { type: "string" } } }; + const annotations = { consequentialHint: true }; + await context.registerTool({ + name: "z", + description: "Z", + inputSchema: schema, + annotations, + execute: () => null, + }); + await context.registerTool({ name: "a", description: "A", execute: () => null }); + schema.properties.query.type = "number"; + annotations.consequentialHint = false; + const tools = await context.getTools(); + const firstSchema = JSON.stringify(tools[1].inputSchema); + Object.assign(tools[1].inputSchema!, { mutated: true }); + tools[1].annotations!.consequentialHint = false; + const again = await context.getTools(); + return { + names: tools.map((tool) => tool.name), + firstSchema, + nextSchema: JSON.stringify(again[1].inputSchema), + annotations: again[1].annotations, + noSchema: Object.hasOwn(again[0], "inputSchema"), + noAnnotations: Object.hasOwn(again[0], "annotations"), + origin: again[0].origin, + window: again[0].window === window, + }; + }); + expect(result.names).toEqual(["a", "z"]); + expect(result.firstSchema).toBe(result.nextSchema); + expect(JSON.parse(result.nextSchema!)).toEqual({ + type: "object", + properties: { query: { type: "string" } }, + }); + expect(result.annotations).toEqual({ + consequentialHint: true, + readOnlyHint: false, + untrustedContentHint: false, + }); + expect(result).toMatchObject({ + noSchema: false, + noAnnotations: false, + origin: "http://localhost:8793", + window: true, + }); +}); + +test("rejects invalid descriptors, duplicates and unserializable schemas", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const { rejections, registered } = await page.evaluate(async () => { + const context = document.modelContext!; + const good = { name: "valid", description: "Valid", execute: () => null }; + await context.registerTool(good); + const circular = {}; + Object.assign(circular, { self: circular }); + const cases: [string, unknown][] = [ + ["null descriptor", null], + ["execute is not callable", { ...good, execute: 1 }], + ["name is a Symbol", { ...good, name: Symbol() }], + ["name is already registered", good], + ["name is empty", { ...good, name: "" }], + ["name has a disallowed character", { ...good, name: "invalid name" }], + ["name exceeds 128 characters", { ...good, name: "a".repeat(129) }], + ["description is empty", { ...good, description: "" }], + ["inputSchema is not an object", { ...good, name: "bad-schema", inputSchema: null }], + ["inputSchema is circular", { ...good, name: "circular", inputSchema: circular }], + [ + "inputSchema serializes to undefined", + { ...good, name: "undefined", inputSchema: { toJSON: () => undefined } }, + ], + ]; + const results: [string, string][] = []; + for (const [label, descriptor] of cases) { + try { + // @ts-expect-error Exercise invalid JavaScript callers at the Web IDL boundary. + await context.registerTool(descriptor); + results.push([label, "resolved"]); + } catch (error) { + if (!(error instanceof Error)) throw error; + results.push([label, error.name]); + } + } + return { rejections: results, registered: (await context.getTools()).map((tool) => tool.name) }; + }); + expect(rejections).toEqual([ + ["null descriptor", "TypeError"], + ["execute is not callable", "TypeError"], + ["name is a Symbol", "TypeError"], + ["name is already registered", "InvalidStateError"], + ["name is empty", "InvalidStateError"], + ["name has a disallowed character", "InvalidStateError"], + ["name exceeds 128 characters", "InvalidStateError"], + ["description is empty", "InvalidStateError"], + ["inputSchema is not an object", "TypeError"], + ["inputSchema is circular", "TypeError"], + ["inputSchema serializes to undefined", "TypeError"], + ]); + // A rejected registration must not leave a tool behind. + expect(registered).toEqual(["valid"]); +}); + +test("coerces registration dictionary members", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const result = await page.evaluate(async () => { + "use strict"; + const context = document.modelContext!; + const descriptor = { + name: 123, + title: "\ud800", + description: true, + annotations: { readOnlyHint: 1 }, + execute: () => null, + }; + // @ts-expect-error Web IDL coerces the deliberately non-string fields. + await context.registerTool(descriptor); + const [tool] = await context.getTools(); + return { + name: tool.name, + title: tool.title, + description: tool.description, + annotations: tool.annotations, + }; + }); + expect(result).toEqual({ + name: "123", + title: "\ufffd", + description: "true", + annotations: { consequentialHint: false, readOnlyHint: true, untrustedContentHint: false }, + }); +}); + +test("queues toolchange and resolves registration after notification; abort unregisters", async ({ + page, +}) => { + await page.addScriptTag({ url: "/auto.js" }); + const result = await page.evaluate(async () => { + const context = document.modelContext!; + const registration = new AbortController(); + const events: string[] = []; + context.ontoolchange = () => events.push("change"); + const pending = context.registerTool( + { name: "x", description: "X", execute: () => null }, + { signal: registration.signal }, + ); + events.push("sync"); + await Promise.resolve(); + events.push("microtask"); + await pending; + events.push("registered"); + registration.abort(); + const tools = await context.getTools(); + return { events, count: tools.length }; + }); + expect(result).toEqual({ + events: ["sync", "microtask", "change", "registered", "change"], + count: 0, + }); +}); + +test("preserves event-handler listener order when the handler is replaced", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + const order: string[] = []; + context.addEventListener("toolchange", () => order.push("first")); + context.ontoolchange = () => order.push("old"); + context.addEventListener("toolchange", () => order.push("last")); + context.ontoolchange = function () { + order.push(this === context ? "new" : "wrong-this"); + }; + await context.registerTool({ name: "x", description: "X", execute: () => null }); + // Clearing the handler removes its listener, so setting one again appends at the end. + context.ontoolchange = null; + context.ontoolchange = () => order.push("reassigned"); + await context.registerTool({ name: "y", description: "Y", execute: () => null }); + return order; + }), + ).toEqual(["first", "new", "last", "first", "last", "reassigned"]); +}); + +test("rejects aborted registration and permits reusing its name", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + const descriptor = { name: "x", description: "X", execute: () => null }; + const controller = new AbortController(); + controller.signal.addEventListener("abort", (event) => event.stopImmediatePropagation()); + const pending = context + .registerTool(descriptor, { signal: controller.signal }) + .catch((error) => error); + controller.abort("cancel-registration"); + const reason = await pending; + await context.registerTool(descriptor); + return [reason, (await context.getTools()).length]; + }), + ).toEqual(["cancel-registration", 1]); +}); + +test("validates origins and refuses cross-document exposure", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + const errors = []; + for (const origin of ["invalid", "http://untrusted.test", "https://other.test"]) { + try { + await context.getTools({ fromOrigins: [origin] }); + } catch (error) { + if (!(error instanceof Error)) throw error; + errors.push(error.name); + } + } + try { + await context.registerTool( + { name: "x", description: "X", execute() {} }, + { exposedTo: ["https://other.test"] }, + ); + } catch (error) { + if (!(error instanceof Error)) throw error; + errors.push(error.name); + } + return errors; + }), + ).toEqual(["SecurityError", "SecurityError", "NotSupportedError", "NotSupportedError"]); +}); + +test("inactive documents get their own context but cannot register tools", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const inactive = document.implementation.createHTMLDocument(); + const context = inactive.modelContext!; + let errorName = ""; + try { + await context.registerTool({ name: "x", description: "X", execute() {} }); + } catch (error) { + if (!(error instanceof Error)) throw error; + errorName = error.name; + } + return { + same: context === inactive.modelContext, + distinct: context !== document.modelContext, + errorName, + }; + }), + ).toEqual({ same: true, distinct: true, errorName: "InvalidStateError" }); +}); + +test("a detached frame rejects even when its exception constructor was never read", async ({ + page, +}) => { + // The /app iframe installs the polyfill; this document deliberately does not. + const result = await page.evaluate(async () => { + const iframe = document.createElement("iframe"); + iframe.src = "/app"; + const loaded = new Promise((resolve) => { + iframe.onload = () => resolve(); + }); + document.body.append(iframe); + await loaded; + const context = iframe.contentDocument!.modelContext!; + iframe.remove(); + try { + await context.getTools(); + return "resolved"; + } catch (error) { + if (!error || typeof error !== "object" || !("name" in error)) throw error; + return { name: error.name, type: Object.prototype.toString.call(error) }; + } + }); + expect(result).toEqual({ name: "InvalidStateError", type: "[object DOMException]" }); +}); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..5d179ff --- /dev/null +++ b/index.ts @@ -0,0 +1,355 @@ +import type { WebMCP } from "webmcp-types"; +export type { WebMCP } from "webmcp-types"; + +/*! + * Copyright (c) 2026 WebMCP polyfill contributors + * SPDX-License-Identifier: MIT + */ + +// Capture the constructor before a detached WindowProxy stops exposing it. +const NativeDOMException = globalThis.DOMException; + +interface Tool { + metadata: Omit; + /** + * Stored serialized rather than as an object: registration snapshots the schema so later + * mutation of the author's object is invisible, every `getTools()` hands back an independent + * deep copy, and a schema that cannot be serialized fails at registration time. + */ + schema?: string; + execute: WebMCP.ToolExecuteCallback; +} + +function isObject(value: unknown): value is object { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +// https://webidl.spec.whatwg.org/#es-dictionary +function dictionary(value: unknown): Record { + if (value == null) return {}; + if (!isObject(value)) throw new TypeError("Expected a dictionary"); + // SAFETY: Web IDL dictionaries admit any object; members remain unknown until converted. + return value as Record; +} + +function toolAnnotations(value: unknown): WebMCP.ToolAnnotations | undefined { + if (value === undefined) return undefined; + const annotations = dictionary(value); + return { + consequentialHint: Boolean(annotations.consequentialHint), + readOnlyHint: Boolean(annotations.readOnlyHint), + untrustedContentHint: Boolean(annotations.untrustedContentHint), + }; +} + +// https://webidl.spec.whatwg.org/#es-DOMString +function domString(value: unknown): string { + if (typeof value === "symbol") throw new TypeError("Cannot convert a Symbol to a string"); + return String(value); +} + +function required(value: unknown, name: string): unknown { + if (value === undefined) throw new TypeError(`${name} is required`); + return value; +} + +function serialize(value: unknown): string { + const result = JSON.stringify(value); + if (result === undefined) throw new TypeError("Value is not JSON-serializable"); + return result; +} + +function signalOption(value: unknown): AbortSignal | undefined { + // Native composition validates the signal and still follows abort when an earlier listener + // on the caller's own signal calls stopImmediatePropagation. The draft registers an abort + // algorithm, which runs before the abort event; JavaScript cannot register one. + // SAFETY: AbortSignal.any performs the native brand check, including across realms. + return value === undefined ? undefined : AbortSignal.any([value as AbortSignal]); +} + +// https://webidl.spec.whatwg.org/#es-sequence +function originSequence(value: unknown): string[] { + if (value === undefined) return []; + if (!isObject(value)) throw new TypeError("Origins must be a sequence"); + const iterator: unknown = Reflect.get(value, Symbol.iterator); + if (typeof iterator !== "function") throw new TypeError("Origins must be a sequence"); + // Web IDL gets the iterator method once and calls it with the original receiver. + return Array.from({ [Symbol.iterator]: () => Reflect.apply(iterator, value, []) }, (origin) => + domString(origin).toWellFormed(), + ); +} + +// Every non-empty list is refused; the loop only decides which failure the caller sees, so an +// origin that native WebMCP would reject outright keeps failing with SecurityError instead of +// being masked by the polyfill's NotSupportedError. +// +// This approximates https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy on +// the URL's own scheme and host. The draft evaluates the URL's origin, so a scheme that +// inherits an inner origin (`blob:`) is judged on the outer scheme here; that only changes +// which of the two rejections a caller sees. +function rejectUnsupportedOrigins(origins: string[]): void { + for (const origin of origins) { + let url: URL; + try { + url = new URL(origin); + } catch { + throw new NativeDOMException("Invalid origin", "SecurityError"); + } + const local = + url.hostname === "[::1]" || + // Loopback is a CIDR match on 127.0.0.0/8, not a prefix: the URL parser canonicalizes + // every numeric form to dotted-quad, so "127.example.test" is a domain, not loopback. + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(url.hostname) || + url.hostname === "localhost" || + url.hostname === "localhost." || + url.hostname.endsWith(".localhost") || + url.hostname.endsWith(".localhost."); + if ( + !["https:", "wss:", "file:"].includes(url.protocol) && + !(["http:", "ws:"].includes(url.protocol) && local) + ) { + throw new NativeDOMException("Origin is not potentially trustworthy", "SecurityError"); + } + } + if (origins.length) { + throw new NativeDOMException("Cross-document tools require native WebMCP", "NotSupportedError"); + } +} + +function activeView(owner: Document): Window { + const view = owner.defaultView; + if (!view || view.document !== owner || (view.frameElement && !view.frameElement.isConnected)) { + throw new NativeDOMException("The document is not fully active", "InvalidStateError"); + } + if (view.originAgentCluster === false && view.location.protocol !== "file:") { + throw new NativeDOMException("An origin-keyed agent cluster is required", "SecurityError"); + } + // Chromium is the only engine shipping either name, and it ships `featurePolicy` unflagged + // while `permissionsPolicy` is still behind an experimental flag, so the second branch is the + // one that runs today. `features()` is consulted first because a browser with the policy + // object but no `tools` feature would otherwise get a meaningless default from allowsFeature. + const policy = + ("permissionsPolicy" in owner ? owner.permissionsPolicy : undefined) ?? + ("featurePolicy" in owner ? owner.featurePolicy : undefined); + if ( + isObject(policy) && + "features" in policy && + "allowsFeature" in policy && + typeof policy.features === "function" && + typeof policy.allowsFeature === "function" && + policy.features().includes("tools") + ) { + if (policy.allowsFeature("tools")) return view; + throw new NativeDOMException("WebMCP is disabled by Permissions Policy", "NotAllowedError"); + } + // With no usable policy surface the document cannot be asked whether WebMCP is permitted, so + // same-origin access stands in for the feature's `self` default allowlist. + try { + void view.parent.document; + } catch { + throw new NativeDOMException( + "Cross-origin frames require native Permissions Policy", + "NotAllowedError", + ); + } + return view; +} + +// Timers approximate the unavailable WebMCP task source; exact +// inter-source ordering requires native support. +function queueTask(callback: () => void): void { + setTimeout(callback, 0); +} + +// Parameters carry `= …` defaults rather than `?` because Web IDL fixes each operation's +// `length` at its required-argument count, and only defaults reduce the length TypeScript emits. +class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { + readonly #owner: Document; + readonly #tools = new Map(); + #handler: WebMCP.ModelContext["ontoolchange"] = null; + readonly #listener = (event: Event): void => { + this.#handler?.call(this, event); + }; + + constructor(owner: Document) { + super(); + this.#owner = owner; + } + + get ontoolchange(): WebMCP.ModelContext["ontoolchange"] { + return this.#handler; + } + + set ontoolchange(handler: WebMCP.ModelContext["ontoolchange"]) { + const next = typeof handler === "function" ? handler : null; + // Replacing one callable with another keeps the handler's place in the listener list, which + // is why the listener is not re-registered. Clearing it deactivates the handler and removes + // the listener, so setting a callable again appends a new one at the end. + if (!this.#handler && next) this.addEventListener("toolchange", this.#listener); + if (this.#handler && !next) this.removeEventListener("toolchange", this.#listener); + this.#handler = next; + } + + async registerTool( + tool: object, + options: WebMCP.ModelContextRegisterToolOptions = {}, + ): Promise { + const descriptor = dictionary(tool); + const annotations = toolAnnotations(descriptor.annotations); + const description = domString(required(descriptor.description, "description")); + const callback = required(descriptor.execute, "execute"); + if (typeof callback !== "function") throw new TypeError("execute must be a function"); + // SAFETY: Web IDL requires callability only. + const execute = callback as WebMCP.ToolExecuteCallback; + const inputSchema = descriptor.inputSchema; + if (inputSchema !== undefined && !isObject(inputSchema)) + throw new TypeError("inputSchema must be an object"); + const name = domString(required(descriptor.name, "name")); + const rawTitle = descriptor.title; + const title = rawTitle === undefined ? "" : domString(rawTitle).toWellFormed(); + const settings = dictionary(options); + const exposedTo = originSequence(settings.exposedTo); + const signal = signalOption(settings.signal); + + const view = activeView(this.#owner); + // Duplicate, then name, then description: the draft's order. + if (this.#tools.has(name)) { + throw new NativeDOMException(`A tool named ${name} is already registered`, "InvalidStateError"); + } + if (!/^[A-Za-z0-9_.-]{1,128}$/u.test(name)) { + throw new NativeDOMException( + `Tool names are 1 to 128 characters of ASCII alphanumerics, "_", "-" or ".": ${name}`, + "InvalidStateError", + ); + } + if (!description) { + throw new NativeDOMException("A tool description cannot be empty", "InvalidStateError"); + } + const schema = inputSchema === undefined ? undefined : serialize(inputSchema); + signal?.throwIfAborted(); + rejectUnsupportedOrigins(exposedTo); + + const entry: Tool = { + metadata: { name, title, description, window: view, origin: view.origin }, + schema, + execute, + }; + if (annotations !== undefined) entry.metadata.annotations = annotations; + return new Promise((resolve, reject) => { + // One listener covers both roles: before `resolve` runs it rejects and rolls the + // registration back, and afterwards `reject` is inert so it only unregisters. Nothing else + // ever removes this entry, since registering the name again while it is held throws. + signal?.addEventListener( + "abort", + () => { + this.#tools.delete(name); + queueTask(() => this.dispatchEvent(new Event("toolchange"))); + reject(signal.reason); + }, + { once: true }, + ); + this.#tools.set(name, entry); + queueTask(() => this.dispatchEvent(new Event("toolchange"))); + queueTask(resolve); + }); + } + + async getTools( + options: WebMCP.ModelContextGetToolOptions = {}, + ): Promise { + const fromOrigins = originSequence(dictionary(options).fromOrigins); + activeView(this.#owner); + rejectUnsupportedOrigins(fromOrigins); + const tools = [...this.#tools.values()] + // Web IDL creates a dictionary's members in lexicographical order. + .map(({ metadata, schema }): WebMCP.RegisteredTool => ({ + ...(metadata.annotations && { annotations: { ...metadata.annotations } }), + description: metadata.description, + ...(schema !== undefined && { inputSchema: JSON.parse(schema) as object }), + name: metadata.name, + origin: metadata.origin, + title: metadata.title, + window: metadata.window, + })) + // The draft sorts by name, comparing code units, which is what `<` does on strings. + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return new Promise((resolve) => queueTask(() => resolve(tools))); + } +} + +const contexts = new WeakMap(); + +// Web IDL interface-object plumbing. A conforming `ModelContext` exposes a non-constructible +// interface object inheriting EventTarget, a prototype branded `[object ModelContext]`, and +// enumerable prototype members, none of which a plain class produces. +const modelContextConstructor = function ModelContext(): never { + throw new TypeError("Illegal constructor"); +}; +// Not redundant with the function expression's name: the shipped bundle is minified and the +// minifier drops that binding, leaving `name` empty. The interface-shape test asserts it. +Object.defineProperty(modelContextConstructor, "name", { value: "ModelContext" }); +Object.defineProperty(modelContextConstructor, "prototype", { + value: ModelContextPolyfill.prototype, + writable: false, +}); +Object.setPrototypeOf(modelContextConstructor, EventTarget); +Object.defineProperties(ModelContextPolyfill.prototype, { + constructor: { value: modelContextConstructor, configurable: true, writable: true }, + [Symbol.toStringTag]: { value: "ModelContext", configurable: true }, + // Class members are non-enumerable; Web IDL interface members are enumerable. + registerTool: { enumerable: true }, + getTools: { enumerable: true }, + ontoolchange: { enumerable: true }, +}); + +/** + * Install the document-local WebMCP API when the current realm has no implementation. + * + * Does nothing without a `Document`, outside a secure context, or where `document.modelContext` + * already exists, so repeat calls are safe and a native implementation is never replaced. Only + * the calling realm is affected; each frame installs separately. + * + * @throws {TypeError} when the realm cannot be extended, rather than installing halfway. + */ +export function installWebMCP(): void { + if (typeof document === "undefined" || !globalThis.isSecureContext || "modelContext" in document) + return; + const prototype = Document.prototype; + const defaultViewGetter = Object.getOwnPropertyDescriptor(prototype, "defaultView")!.get!; + const constructorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "ModelContext"); + if ( + !Object.isExtensible(prototype) || + (constructorDescriptor && !constructorDescriptor.configurable) || + (!constructorDescriptor && !Object.isExtensible(globalThis)) + ) { + throw new TypeError("Cannot install WebMCP on this realm"); + } + Object.defineProperty(globalThis, "ModelContext", { + value: modelContextConstructor, + configurable: true, + writable: true, + }); + // The native `defaultView` getter rejects a foreign receiver with "Illegal invocation", which + // is the brand check Web IDL requires of `get modelContext`. Its value is unused. + // + // A concise method, not a function declaration: attribute getters are not constructible and + // have no `prototype` property, and only a method gets both. + const { getModelContext } = { + getModelContext(this: Document): WebMCP.ModelContext { + defaultViewGetter.call(this); + let context = contexts.get(this); + if (!context) { + context = new ModelContextPolyfill(this); + contexts.set(this, context); + } + return context; + }, + }; + // A getter defined through a descriptor is named "get"; Web IDL requires "get modelContext". + Object.defineProperty(getModelContext, "name", { value: "get modelContext" }); + Object.defineProperty(prototype, "modelContext", { + configurable: true, + enumerable: true, + get: getModelContext, + }); +} diff --git a/native.test.ts b/native.test.ts new file mode 100644 index 0000000..5d97aff --- /dev/null +++ b/native.test.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; + +test("loading the polyfill preserves the real native context and registered tools", async ({ + page, + browser, +}, testInfo) => { + await page.goto("/"); + expect(await page.evaluate(() => typeof document.modelContext?.registerTool)).toBe("function"); + const original = await page.evaluateHandle(async () => { + const context = document.modelContext; + const getter = Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!.get; + await document.modelContext!.registerTool({ + name: "native", + description: "Native tool", + execute: () => ({ native: true }), + }); + return { context, getter }; + }); + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate( + async ({ context, getter }) => ({ + sameContext: document.modelContext === context, + sameGetter: + Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!.get === getter, + tools: (await document.modelContext!.getTools()).map((tool) => tool.name), + }), + original, + ), + ).toEqual({ sameContext: true, sameGetter: true, tools: ["native"] }); + await testInfo.attach("browser.json", { + body: JSON.stringify({ version: browser.version() }), + contentType: "application/json", + }); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..c28027a --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "webmcp-polyfill", + "version": "0.1.0", + "description": "A document-local polyfill for WebMCP", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/webmachinelearning/webmcp-polyfill.git" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": [ + "./dist/auto.js" + ], + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./auto": "./dist/auto.js" + }, + "scripts": { + "build": "tsc && esbuild auto.ts --bundle --format=iife --target=es2022 --minify --legal-comments=inline --outfile=dist/polyfill.js", + "typecheck": "tsc -p tsconfig.test.json", + "test": "pnpm lint && pnpm build && pnpm typecheck && playwright test", + "test:wpt": "pnpm build && node wpt.mjs", + "prepack": "pnpm build", + "test:package": "node package.test.mjs", + "lint": "oxlint --deny-warnings" + }, + "dependencies": { + "webmcp-types": "^0.1.7" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@types/node": "^24.13.4", + "esbuild": "^0.25.0", + "oxlint": "1.82.0", + "typescript": "^5.9.0" + }, + "packageManager": "pnpm@10.14.0", + "pnpm": { + "onlyBuiltDependencies": [] + } +} diff --git a/package.test.mjs b/package.test.mjs new file mode 100644 index 0000000..7af0308 --- /dev/null +++ b/package.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const consumer = mkdtempSync(join(tmpdir(), "webmcp-consumer-")); +const root = fileURLToPath(new URL(".", import.meta.url)); +function run(command, args, cwd = consumer) { + const result = spawnSync(command, args, { cwd, stdio: "inherit" }); + if (result.error) throw result.error; + assert.equal(result.status, 0, `${command} ${args.join(" ")} failed`); +} +try { + run("pnpm", ["pack", "--pack-destination", consumer], root); + const archive = readdirSync(consumer).find((name) => name.endsWith(".tgz")); + assert.ok(archive, "pnpm pack produced no tarball"); + writeFileSync(join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" })); + // Resolve declared dependencies with a fresh metadata cache, as a clean CI runner does. + run("pnpm", [ + "add", + "--cache-dir", + join(consumer, "cache"), + "--ignore-scripts", + join(consumer, archive), + ]); + const installed = join(consumer, "node_modules/webmcp-polyfill"); + const files = readdirSync(installed, { + recursive: true, + withFileTypes: true, + }) + .filter((entry) => entry.isFile()) + .map((entry) => relative(installed, join(entry.parentPath, entry.name)).replaceAll("\\", "/")) + .sort(); + assert.deepEqual(files, [ + "LICENSE", + "README.md", + "dist/auto.d.ts", + "dist/auto.js", + "dist/index.d.ts", + "dist/index.js", + "dist/polyfill.js", + "package.json", + ]); + assert.match( + readFileSync(join(installed, "dist/polyfill.js"), "utf8"), + /SPDX-License-Identifier: MIT/, + "dist/polyfill.js lost its licence banner: check esbuild's --legal-comments=inline", + ); + // Compile an auto-only consumer: source files in this checkout cannot supply + // missing ambient declarations or mask broken package exports. + writeFileSync( + join(consumer, "consumer.ts"), + ` + import 'webmcp-polyfill/auto'; + const context: WebMCP.ModelContext | undefined = document.modelContext; + void context?.registerTool({ name: 'typed', description: 'Typed', + inputSchema: { type: 'object', properties: { count: { type: 'number' } }, required: ['count'] }, + execute(input) { + const count: number = input.count; + // @ts-expect-error inferred count must not become any + const invalid: string = input.count; + return { count }; + }, + }); + async function discover(context: WebMCP.ModelContext) { + const tools: WebMCP.RegisteredTool[] = await context.getTools(); + return tools; + } + `, + ); + for (const [module, moduleResolution] of [ + ["NodeNext", "NodeNext"], + ["ESNext", "Bundler"], + ]) { + run(process.execPath, [ + join(root, "node_modules/typescript/bin/tsc"), + "--strict", + "--noEmit", + "--target", + "ES2022", + "--module", + module, + "--moduleResolution", + moduleResolution, + "consumer.ts", + ]); + } + run(process.execPath, [ + "--input-type=module", + "-e", + "import { installWebMCP } from 'webmcp-polyfill'; import 'webmcp-polyfill/auto'; installWebMCP();", + ]); + console.log( + "Packed consumer: public types, inference, SSR imports, and package contents passed.", + ); +} finally { + rmSync(consumer, { recursive: true, force: true }); +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5e731e4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testMatch: /(?:index|app)\.test\.ts$/, + forbidOnly: !!process.env.CI, + fullyParallel: true, + workers: 3, + reporter: [["list"], ["html", { open: "never" }]], + use: { baseURL: "http://localhost:8793", trace: "retain-on-failure" }, + webServer: { + command: "node fixtures/server.mjs", + url: "http://localhost:8793/health", + reuseExistingServer: false, + }, + projects: [ + { + name: "chromium", + use: { + browserName: "chromium", + launchOptions: { args: ["--disable-features=WebMCP,WebMCPTesting"] }, + }, + }, + { name: "firefox", use: { browserName: "firefox" } }, + { name: "webkit", use: { browserName: "webkit" } }, + { + name: "native-chromium", + testMatch: "native.test.ts", + use: { + browserName: "chromium", + launchOptions: { args: ["--enable-experimental-web-platform-features"] }, + }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..64d1b3c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,556 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + webmcp-types: + specifier: ^0.1.7 + version: 0.1.7 + devDependencies: + '@playwright/test': + specifier: ^1.55.0 + version: 1.63.0 + '@types/node': + specifier: ^24.13.4 + version: 24.13.4 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + oxlint: + specifier: 1.82.0 + version: 1.82.0 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.82.0': + resolution: {integrity: sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.82.0': + resolution: {integrity: sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.82.0': + resolution: {integrity: sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.82.0': + resolution: {integrity: sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.82.0': + resolution: {integrity: sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + resolution: {integrity: sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.82.0': + resolution: {integrity: sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.82.0': + resolution: {integrity: sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.82.0': + resolution: {integrity: sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.82.0': + resolution: {integrity: sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.82.0': + resolution: {integrity: sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.82.0': + resolution: {integrity: sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.82.0': + resolution: {integrity: sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.82.0': + resolution: {integrity: sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.82.0': + resolution: {integrity: sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.82.0': + resolution: {integrity: sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.82.0': + resolution: {integrity: sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.82.0': + resolution: {integrity: sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.82.0': + resolution: {integrity: sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + + '@types/node@24.13.4': + resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + oxlint@1.82.0: + resolution: {integrity: sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + webmcp-types@0.1.7: + resolution: {integrity: sha512-70YszESbCx+ozBejmBPTSc0hMyXim+/Kpj8UOZsHd36ANcYqIfkDbMBTTn5y+tQzQYUYN1x2R+5PseKJtVaLqA==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@oxlint/binding-android-arm-eabi@1.82.0': + optional: true + + '@oxlint/binding-android-arm64@1.82.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.82.0': + optional: true + + '@oxlint/binding-darwin-x64@1.82.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.82.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.82.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.82.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.82.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.82.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.82.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.82.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.82.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.82.0': + optional: true + + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + + '@types/node@24.13.4': + dependencies: + undici-types: 7.18.2 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + oxlint@1.82.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.82.0 + '@oxlint/binding-android-arm64': 1.82.0 + '@oxlint/binding-darwin-arm64': 1.82.0 + '@oxlint/binding-darwin-x64': 1.82.0 + '@oxlint/binding-freebsd-x64': 1.82.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.82.0 + '@oxlint/binding-linux-arm-musleabihf': 1.82.0 + '@oxlint/binding-linux-arm64-gnu': 1.82.0 + '@oxlint/binding-linux-arm64-musl': 1.82.0 + '@oxlint/binding-linux-ppc64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-musl': 1.82.0 + '@oxlint/binding-linux-s390x-gnu': 1.82.0 + '@oxlint/binding-linux-x64-gnu': 1.82.0 + '@oxlint/binding-linux-x64-musl': 1.82.0 + '@oxlint/binding-openharmony-arm64': 1.82.0 + '@oxlint/binding-win32-arm64-msvc': 1.82.0 + '@oxlint/binding-win32-ia32-msvc': 1.82.0 + '@oxlint/binding-win32-x64-msvc': 1.82.0 + + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + webmcp-types@0.1.7: {} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4ae37f6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "lib": ["ESNext", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "dist", + "types": [], + "verbatimModuleSyntax": true + }, + "files": ["index.ts", "auto.ts"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..6fa915b --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true, "types": ["node"], "noUncheckedSideEffectImports": true }, + "files": [ + "index.test-d.ts", + "index.test.ts", + "app.test.ts", + "native.test.ts", + "playwright.config.ts" + ] +} diff --git a/wpt.mjs b/wpt.mjs new file mode 100644 index 0000000..3af4b0a --- /dev/null +++ b/wpt.mjs @@ -0,0 +1,108 @@ +import { readFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; + +const root = process.env.WPT_ROOT; +const chrome = process.env.CHROME_BIN; +if (!root || !chrome) + throw new Error("Set WPT_ROOT to a WPT checkout and CHROME_BIN to Chrome Canary"); + +const revision = "1a21db90adf8a264370ad806ed761f39e1d435a0"; +// Pinned together with `revision` and `tests`; keep TESTING.md in sync. +const EXPECTED_ASSERTIONS = 27; +const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }); +if (head.error) throw head.error; +if (head.status !== 0 || head.stdout.trim() !== revision) { + throw new Error(`Check out WPT ${revision}; review upstream changes before changing the pin`); +} + +const clean = spawnSync("git", ["diff", "--quiet", "HEAD", "--"], { cwd: root }); +if (clean.status !== 0) { + throw new Error("WPT has tracked changes; restore the pinned sources before running conformance"); +} + +// Unmodified upstream registration/discovery tests. Execution and full IDL +// coverage belong to the executeTool follow-up. +const tests = [ + "imperative/register_tool_name_validation.https.html", + "imperative/register_tool_signal.https.html", + "imperative/register_tool_with_schema.https.html", + "imperative/register_tool_no_schema.https.html", + "imperative/register_tool_invalid_json_schema.https.html", + "imperative/register_tool_toolchange.https.html", + "imperative/duplicate_tool_registration.https.html", + "imperative/getTools-imperative-schema.https.html", + "imperative/model_context.https.html", + "imperative/non-secure.html", + "imperative/register-tool-title.https.html", + "imperative/register_tool_with_empty_annotation.https.html", + "imperative/getTools-imperative-annotations.https.html", +]; +const report = fileURLToPath(new URL("./wpt-results/report.json", import.meta.url)); +mkdirSync(dirname(report), { recursive: true }); +rmSync(report, { force: true }); +for (const test of tests) { + const source = test.replace(/\.https\.window\.html$/, ".https.window.js"); + if (!existsSync(resolve(root, "webmcp", source))) + throw new Error(`Missing WPT source: ${source}`); +} +const result = spawnSync( + process.env.WPT_PYTHON ?? "python3", + [ + resolve(root, "wpt"), + "--venv", + process.env.WPT_VENV ?? resolve(root, "_venv_polyfill"), + "run", + "--channel", + "canary", + "--binary", + chrome, + "--yes", + "--install-webdriver", + "--headless", + "--no-enable-experimental", + "--test-types", + "testharness", + "--binary-arg=--disable-features=WebMCP,WebMCPTesting", + "--inject-script", + fileURLToPath(new URL("./dist/polyfill.js", import.meta.url)), + "--manifest", + resolve(root, "MANIFEST.json"), + "--log-mach=-", + "--log-wptreport", + report, + "--no-pause-after-test", + "--processes", + "1", + "--no-manifest-download", + ...tests.flatMap((test) => ["--include", `/webmcp/${test}`]), + "chrome", + ], + { cwd: root, stdio: "inherit" }, +); +if (result.error) throw result.error; +if (result.status !== 0) throw new Error(`WPT reported unexpected results (exit ${result.status})`); +if (!existsSync(report)) throw new Error("WPT produced no report"); +const { results } = JSON.parse(readFileSync(report, "utf8")); +const actual = results.map(({ test }) => test).sort(); +const expected = tests.map((test) => `/webmcp/${test}`).sort(); +assert.deepEqual( + actual, + expected, + "WPT selection did not run exactly once: inspect wpt-results/report.json", +); +const empty = results.filter((entry) => !entry.subtests.length).map(({ test }) => test); +if (empty.length) throw new Error(`WPT files ran no assertions: ${empty.join(", ")}`); +const assertions = results.reduce((count, entry) => count + entry.subtests.length, 0); +if (assertions !== EXPECTED_ASSERTIONS) { + const counts = results.map(({ test, subtests }) => ` ${test}: ${subtests.length}`).join("\n"); + throw new Error( + `Expected ${EXPECTED_ASSERTIONS} assertions at WPT ${revision}, got ${assertions}.\n${counts}\n` + + "Fewer means a testharness file stopped early, which expected-failure metadata cannot " + + "catch: fix the polyfill. More, or a deliberate change to `revision` or `tests`, means " + + "updating EXPECTED_ASSERTIONS here and the counts in TESTING.md.", + ); +} +console.log(`WPT: ${results.length} files, ${assertions} assertions. Report: ${report}`); From 0cb2b89e65b6e487fcc8da22dd19dad32148e147 Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Mon, 14 Sep 2026 08:10:30 -0700 Subject: [PATCH 2/9] feat(polyfill): add executeTool with execution conformance tests Implement executeTool() against the current draft: object input, JSON-serialized results, and abort handling that gives the callback its own signal and a default AbortError while the caller receives its own reason. Extend the WPT selection from 13 files to 18, and from 27 assertions to 56, now that the interface is complete enough for the IDL harness. Record as per-subtest expected failures in wpt-metadata the four upstream files that disagree with the draft on omitted input and on result serialization, rather than patching tests or relaxing the implementation. Carry a temporary ModelContext augmentation mirroring webmcp-types#3 until that ships. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 6 +- README.md | 7 +- TESTING.md | 99 +++- app.test.ts | 14 +- execute.test.ts | 537 ++++++++++++++++++ fixtures/app.js | 10 + fixtures/server.mjs | 4 +- index.test-d.ts | 13 +- index.test.ts | 32 +- index.ts | 132 ++++- package.test.mjs | 9 +- playwright.config.ts | 2 +- tsconfig.test.json | 1 + ...teTool-error-window-onerror.https.html.ini | 6 + ...cuteTool-invalid-dictionary.https.html.ini | 4 + ...-unregister-resolution-race.https.html.ini | 4 + .../object-arguments.https.html.ini | 4 + wpt.mjs | 13 +- 18 files changed, 834 insertions(+), 63 deletions(-) create mode 100644 execute.test.ts create mode 100644 wpt-metadata/webmcp/imperative/executeTool-error-window-onerror.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/executeTool-invalid-dictionary.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/executeTool-unregister-resolution-race.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/object-arguments.https.html.ini diff --git a/AGENTS.md b/AGENTS.md index 41ea58d..4329c46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # Working on the polyfill -This package implements document-local WebMCP registration and discovery. Use -the official `webmcp-types` dependency without duplicating or augmenting its -declarations. Execution is deferred to a separate change alongside types PR #3. +This package implements the document-local imperative WebMCP draft. Use the +official `webmcp-types` dependency; do not duplicate its declarations. Keep the +temporary `executeTool` augmentation only until the upstream package ships it. No MCP server, transport, extension product, navigator aliases, or legacy API compatibility belongs here. diff --git a/README.md b/README.md index b13432b..1644019 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ await context.registerTool( { signal: registration.signal }, ); -console.log(await context.getTools()); +const [tool] = await context.getTools(); +console.log(await context.executeTool(tool, {})); // {"title":"WebMCP demo"} // Remove the tool when it is no longer needed. registration.abort(); @@ -49,9 +50,9 @@ To install explicitly, import and call `installWebMCP` from `webmcp-polyfill`. I Tools stay in the current document. Cross-document tools, declarative forms, lifecycle window events, and browser agent integration aren't implemented. Nonempty `exposedTo` and `fromOrigins` options reject. -This version supports registration, discovery, and `toolchange`. It does not invoke registered callbacks; `executeTool()` follows alongside [the types update](https://github.com/webmachinelearning/webmcp-types/pull/3). +`executeTool()` accepts an object and returns a JSON-serialized result. Callbacks must validate their inputs; schema inference provides TypeScript checks only. -The implementation follows [draft source `cc45efc`](https://github.com/webmachinelearning/webmcp/blob/cc45efcaf0/index.bs) and passes all 27 selected assertions of the [upstream WPT](https://github.com/web-platform-tests/wpt/tree/1a21db90adf8a264370ad806ed761f39e1d435a0/webmcp) at the pin, with no expected failures. [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) records the selection and what it excludes. +The implementation follows [draft source `cc45efc`](https://github.com/webmachinelearning/webmcp/blob/cc45efcaf0/index.bs). Of the 56 selected assertions of the [upstream WPT](https://github.com/web-platform-tests/wpt/tree/1a21db90adf8a264370ad806ed761f39e1d435a0/webmcp) at the pin, 51 pass and five fail where the pinned tests disagree with the draft's `executeTool()` input and result rules. [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) records each one and what the selection excludes. The API tracks the draft. Breaking 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 e4a2a4e..19e2b40 100644 --- a/TESTING.md +++ b/TESTING.md @@ -11,14 +11,15 @@ pnpm test pnpm test:package ``` -| Check | What it runs | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `index.test-d.ts` | TypeScript against the built package declarations, including upstream schema inference | -| `index.test.ts` | Built bundle served over HTTP in Chromium (native WebMCP disabled), Firefox, and WebKit; coercion, metadata, events, registration abort, errors, detached documents | -| `app.test.ts` | A served application, real button interactions, discovery, unregistration, and reload | -| `native.test.ts` | Real native Chromium registration, then polyfill loading; context and getter identities must survive | -| `pnpm test:package` | Packed tarball installed into a fresh consumer, public type imports, SSR-safe entry points, and package contents | -| `pnpm test:wpt` | Unmodified upstream registration/discovery WPT in real Chrome Canary, with native WebMCP disabled | +| Check | What it runs | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index.test-d.ts` | TypeScript against the built package declarations, including upstream schema inference | +| `index.test.ts` | Built bundle served over HTTP in Chromium (native WebMCP disabled), Firefox, and WebKit; coercion, metadata, events, errors, detached documents | +| `execute.test.ts` | The same three engines: object input, JSON results, cancellation, concurrent calls, and dispatch-time failures | +| `app.test.ts` | A served application, real button interactions, callback side effects, invalid input, unregistration, and reload | +| `native.test.ts` | Real native Chromium registration, then polyfill loading; context and getter identities must survive | +| `pnpm test:package` | Packed tarball installed into a fresh consumer, public type imports, SSR-safe entry points, and package contents | +| `pnpm test:wpt` | Unmodified upstream WPT and IDL in real Chrome Canary, with native WebMCP disabled | The fixture server binds 127.0.0.1:8793 and sets the required `Origin-Agent-Cluster` header; Playwright never reuses an existing server, so free that port first. @@ -46,39 +47,74 @@ WPT_ROOT=../wpt CHROME_BIN=/path/to/chrome-canary pnpm test:wpt ``` `WPT_PYTHON` and `WPT_VENV` optionally select the interpreter and environment. -The runner checks the revision, rejects tracked source changes, requires all 13 selected files +The runner checks the revision, rejects tracked source changes, requires all 18 selected files to run exactly once, checks assertion counts, and retains `wpt-results/report.json` with browser and upstream revisions. Runner errors, unexpected failures, and unexpected passes fail the command, and nothing is retried. -At this pin, **all 27 selected assertions pass**, with no expected failures. +At this pin, 56 assertions run: **51 pass and five have explicit expected FAIL +metadata**. All 22 IDL assertions pass. API shape coverage does not prove runtime +defaults or complete conformance. + +### Known draft disagreements + +The [published draft](https://webmachinelearning.github.io/webmcp/#dom-modelcontext-executetool) +accepts optional `any inputObject`, rejects non-objects, and has no `{}` default. +It also JSON-serializes callback results, including strings. The pinned WPT +expects a default object and raw string results in the following cases: + +| Upstream file | Expected failures | Local coverage | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| `executeTool-invalid-dictionary.https.html` | Missing tool invoked without input expects `UnknownError`, rather than the draft's earlier input `TypeError` | Descriptor validation and input errors | +| `executeTool-error-window-onerror.https.html` | Both cases omit input and expect execution to start | Explicit object input, callback/serialization failures, and absence of page errors | +| `executeTool-unregister-resolution-race.https.html` | Invocation omits input | Unregistration during an invocation and its successful result | +| `object-arguments.https.html` | Expects an unquoted string result; also expects omitted input to become `{}` | Array/object inputs, rejected primitive inputs, and JSON result serialization | + +These are five assertions in four files, recorded individually in `wpt-metadata`. +The pin, [1a21db9](https://github.com/web-platform-tests/wpt/commit/1a21db90adf8a264370ad806ed761f39e1d435a0), +is the WPT export of the Chromium change for +[spec PR #246](https://github.com/webmachinelearning/webmcp/pull/246) and +[#251](https://github.com/webmachinelearning/webmcp/pull/251), and it rewrote all four of these +files. The draft's input and result rules have not changed since #251 merged, so these are not +stale tests awaiting an update: four of the assertions invoke `executeTool()` with no input, +which the draft rejects with a `TypeError` before it looks the tool up, and `object-arguments` +asserts an unquoted `"Success"` from a callback whose result the draft JSON-serializes. Re-read the live +draft before changing any expectation. An expected failure can stop at its first assertion, so +the local tests cover the behavior after that point. + +`webmcp-types` PR #3 and the augmentation in `index.ts` both declare `inputObject?: object`, +which is narrower than the IDL's `any`: omitting the argument type-checks and then rejects at +runtime, as the draft requires. ### Excluded coverage -Execution tests and the full IDL harness are deferred with `executeTool()`. -The IDL includes that method, so running the full shape suite against this -registration/discovery subset would intentionally fail. Keep that exclusion -explicit rather than adding expected failures for an API not yet included. - -Cross-document discovery, frame-tree routing, declarative forms, browser -permissions integration, CSS states, navigation/BFCache, and lifecycle window -events are also outside this initial implementation. +Cross-document discovery/execution, frame-tree routing, navigation cancellation, +declarative forms, browser permissions integration, CSS states, and lifecycle +window events are outside this initial implementation. The pinned +`executeTool-abort.https.html` is excluded rather than carried as expected +failures: its second subtest waits forever on a `toolactivated` event that the +draft still leaves [unspecified](https://github.com/webmachinelearning/webmcp/issues/146), +so the file times out and its last three subtests never run at all. Expected-failure +metadata cannot express that, and cancellation is exercised directly in all three +engines instead. +`exposedTo-invalid-origins.https.html` is excluded for the `exposedTo` divergence +recorded below, not for cross-document routing. WPT's `--inject-script` modifies testharness pages; it does not install the bundle in `/common/blank.html` helper frames. Detached-frame WPT therefore cannot run unchanged in this lane. The local test serves an instrumented iframe, removes -it, and checks registration, discovery, and exception realms. +it, and checks all three operations and exception realms. ## Where to look when upstream changes -| Source | Use | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| [Draft source and history](https://github.com/webmachinelearning/webmcp/commits/main/index.bs) | Normative algorithms, Web IDL, open issues; compare with the revision below | -| [WPT webmcp](https://github.com/web-platform-tests/wpt/tree/master/webmcp) and [results](https://wpt.fyi/results/webmcp) | Executable assertions and native cross-browser results; these results are not polyfill results | -| [WPT IDL](https://github.com/web-platform-tests/wpt/blob/master/interfaces/webmcp.idl) | Generated interface snapshot; it can lag the published draft | -| [Official types](https://github.com/webmachinelearning/webmcp-types) | Public declarations, schema inference, and pending API updates | -| [Blink script_tools](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/script_tools/) | Chromium IDL, implementation, tests, and commit-linked bugs | -| [Gecko source search](https://searchfox.org/mozilla-central/search?q=ModelContext) and [Mozilla position](https://github.com/mozilla/standards-positions/issues/1412) | Locate Firefox implementation work and discussion; a position is not evidence of shipped support | +| Source | Use | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| [Draft source and history](https://github.com/webmachinelearning/webmcp/commits/main/index.bs) | Normative algorithms, Web IDL, open issues; compare with the revision below | +| [WPT webmcp](https://github.com/web-platform-tests/wpt/tree/master/webmcp) and [results](https://wpt.fyi/results/webmcp) | Executable assertions and native cross-browser results; these results are not polyfill results | +| [WPT IDL](https://github.com/web-platform-tests/wpt/blob/master/interfaces/webmcp.idl) | Generated interface snapshot; it can lag the published draft | +| [Official types](https://github.com/webmachinelearning/webmcp-types) | Public declarations, schema inference, and pending API updates | +| [Blink script_tools](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/script_tools/) | Chromium IDL, implementation, tests, and commit-linked bugs | +| [Gecko source search](https://searchfox.org/mozilla-central/search?q=ModelContext) and [Mozilla position](https://github.com/mozilla/standards-positions/issues/1412) | Locate Firefox implementation work and discussion; a position is not evidence of shipped support | Reproduce a disagreement before changing code or expectations, and record what changed in the draft, types, WPT, and browser implementation separately. @@ -93,7 +129,10 @@ explicit `any`. Warnings fail the command, and `pnpm test` runs it before compil The implementation was compared with [draft source `cc45efc`](https://github.com/webmachinelearning/webmcp/blob/cc45efcaf0/index.bs). It uses timers to queue tasks; JavaScript cannot reproduce the browser's WebMCP task source, nor the draft's abort *algorithms*, which run before an abort event -rather than as a listener. +rather than as a listener. One consequence is observable: a signal aborted before +the dispatch timer fires rejects the caller and never runs the callback, where the +draft dispatches to the target document and then cancels through the callback's own +signal. The runtime checks the `tools` Permissions Policy when the browser exposes it. No engine lists `tools` in `permissionsPolicy.features()` today, so that branch @@ -110,6 +149,10 @@ implementation has nowhere to expose a tool to; rejecting keeps the polyfill fro implying cross-document support it does not have. Origins are validated first, so an untrustworthy one still fails with the `SecurityError` the draft requires. +`webmcp-types@0.1.7` does not declare `executeTool()`. The method augmentation +in `index.ts` is temporary; remove it when [types PR #3](https://github.com/webmachinelearning/webmcp-types/pull/3) +is included in a release. + To test types changes, keep `webmcp-types` beside this checkout and run `pnpm link ../webmcp-types`, then `pnpm typecheck`. Restore the published dependency with `pnpm install --force` before package checks. Do not commit diff --git a/app.test.ts b/app.test.ts index 7e61787..e7d6620 100644 --- a/app.test.ts +++ b/app.test.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; -test("a served application registers, discovers, unregisters, and resets on reload", async ({ +test("a served application executes, rejects invalid input, unregisters, and resets on reload", async ({ page, browser, }, testInfo) => { @@ -12,11 +12,13 @@ test("a served application registers, discovers, unregisters, and resets on relo page.on("pageerror", (error) => errors.push(error)); await page.goto("/app"); await expect(page.locator("#status")).toHaveText("registered"); - expect( - await page.evaluate(async () => - (await document.modelContext!.getTools()).map((tool) => tool.name), - ), - ).toEqual(["increment"]); + await page.getByRole("button", { name: "Execute on page" }).click(); + await expect(page.locator("#result")).toHaveText('{"count":2}'); + await expect(page.locator("#count")).toHaveText("2"); + await page.getByLabel("Amount").fill("-1"); + await page.getByRole("button", { name: "Execute on page" }).click(); + await expect(page.locator("#result")).toHaveText("UnknownError"); + await expect(page.locator("#count")).toHaveText("2"); await page.getByRole("button", { name: "Unregister", exact: true }).click(); await expect(page.locator("#status")).toHaveText("unregistered"); expect(await page.evaluate(() => document.modelContext!.getTools())).toEqual([]); diff --git a/execute.test.ts b/execute.test.ts new file mode 100644 index 0000000..7fe98e4 --- /dev/null +++ b/execute.test.ts @@ -0,0 +1,537 @@ +import { test, expect } from "@playwright/test"; +import "./index.js"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + expect(await page.evaluate(() => "modelContext" in document)).toBe(false); +}); + +test("ignores late results after cancellation, including serialization side effects", async ({ + page, +}) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + let finish!: (value: object) => void; + let serialized = false; + await context.registerTool({ + name: "late", + description: "Late", + execute() { + entered(); + return new Promise((resolve) => { + finish = resolve; + }); + }, + }); + const [tool] = await context.getTools(); + const caller = new AbortController(); + const result = context + .executeTool(tool, {}, { signal: caller.signal }) + .catch((error) => error); + await started; + caller.abort("cancelled"); + await result; + finish({ + toJSON() { + serialized = true; + return {}; + }, + }); + await context.getTools(); + return serialized; + }), + ).toBe(false); +}); + +test("concurrent calls to the same tool have independent cancellation", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + const signals: AbortSignal[] = []; + let start!: () => void; + const bothStarted = new Promise((resolve) => { + start = resolve; + }); + let finish!: (value: object) => void; + await context.registerTool({ + name: "concurrent", + description: "Concurrent", + execute(_, { signal }) { + signals.push(signal); + if (signals.length === 2) start(); + return new Promise((resolve) => { + finish = resolve; + }); + }, + }); + const [tool] = await context.getTools(); + const caller = new AbortController(); + const first = context + .executeTool(tool, {}, { signal: caller.signal }) + .catch((error) => error); + const second = context.executeTool(tool, {}); + await bothStarted; + const aborted = new Promise((resolve) => + signals[0].addEventListener("abort", () => resolve(), { once: true }), + ); + caller.abort("first only"); + await aborted; + finish({ second: true }); + return { + first: await first, + second: await second, + aborted: signals.map((signal) => signal.aborted), + }; + }), + ).toEqual({ first: "first only", second: '{"second":true}', aborted: [true, false] }); +}); + +test("executes copied object and array inputs with a fresh callback signal", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + "use strict"; + const context = document.modelContext!; + const input = { nested: { value: 1 } }; + const caller = new AbortController(); + await context.registerTool({ + name: "echo", + description: "Echo", + execute(args, { signal }) { + return { + args, + fresh: signal instanceof AbortSignal && signal !== caller.signal, + unbound: this === undefined, + }; + }, + }); + const [tool] = await context.getTools(); + const pending = context.executeTool(tool, input, { signal: caller.signal }); + input.nested.value = 9; + return [JSON.parse(await pending), JSON.parse(await context.executeTool(tool, [1, 2]))]; + }), + ).toEqual([ + { args: { nested: { value: 1 } }, fresh: true, unbound: true }, + { args: [1, 2], fresh: true, unbound: true }, + ]); +}); + +test("rejects legacy JSON strings and preserves input serialization errors", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + await context.registerTool({ name: "x", description: "X", execute: () => null }); + const [tool] = await context.getTools(); + const errors = []; + for (const input of [ + "{}", + null, + undefined, + 1, + { + toJSON() { + throw new RangeError("input"); + }, + }, + ]) { + try { + // @ts-expect-error Exercise primitive inputs from JavaScript callers. + await context.executeTool(tool, input); + errors.push("resolved"); + } catch (error) { + if (!(error instanceof Error)) throw error; + errors.push(error.name); + } + } + return errors; + }), + ).toEqual(["TypeError", "TypeError", "TypeError", "TypeError", "RangeError"]); +}); + +test("serializes results as JSON and rejects callback or serialization failures", async ({ + page, +}) => { + const pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + await page.addScriptTag({ url: "/auto.js" }); + const results = await page.evaluate(async () => { + const context = document.modelContext!; + const circular = {}; + Object.assign(circular, { self: circular }); + await context.registerTool({ + name: "x", + description: "X", + execute({ value }) { + if (value === "throw") throw new Error("callback"); + if (value === "circular") return circular; + if (value === "undefined") return undefined; + return value; + }, + }); + const [tool] = await context.getTools(); + const output = []; + for (const value of ["hello", null, 42, { success: true }, "throw", "circular", "undefined"]) { + try { + output.push(await context.executeTool(tool, { value })); + } catch (error) { + if (!(error instanceof Error)) throw error; + output.push(error.name); + } + } + return output; + }); + expect(results).toEqual([ + '"hello"', + "null", + "42", + '{"success":true}', + "UnknownError", + "UnknownError", + "UnknownError", + ]); + expect(pageErrors).toEqual([]); +}); + +test("cancels the caller immediately and sends a default AbortError to the callback", async ({ + page, +}) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + let observed!: (reason: string) => void; + const callbackAborted = new Promise((resolve) => { + observed = resolve; + }); + await context.registerTool({ + name: "pending", + description: "Pending", + execute(_input, { signal }) { + entered(); + return new Promise((resolve) => + signal.addEventListener( + "abort", + () => { + observed(signal.reason.name); + resolve("late result"); + }, + { once: true }, + ), + ); + }, + }); + const [tool] = await context.getTools(); + const controller = new AbortController(); + controller.signal.addEventListener("abort", (event) => event.stopImmediatePropagation()); + const result = context + .executeTool(tool, {}, { signal: controller.signal }) + .catch((error) => error); + await started; + controller.abort("caller reason"); + return [await result, await callbackAborted]; + }), + ).toEqual(["caller reason", "AbortError"]); +}); + +test("unregistration leaves an already-running invocation alive", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + const registration = new AbortController(); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + let complete!: (value: string) => void; + let callbackSignal!: AbortSignal; + await context.registerTool( + { + name: "pending", + description: "Pending", + execute(_input, { signal }) { + callbackSignal = signal; + entered(); + return new Promise((resolve) => { + complete = resolve; + }); + }, + }, + { signal: registration.signal }, + ); + const [tool] = await context.getTools(); + const pending = context.executeTool(tool, {}); + await started; + registration.abort(); + const count = (await context.getTools()).length; + complete("finished"); + return { count, aborted: callbackSignal.aborted, result: await pending }; + }), + ).toEqual({ count: 0, aborted: false, result: '"finished"' }); +}); + +// A polyfill divergence, not a draft requirement: the draft dispatches to the target +// document and cancels through the callback's own signal. See TESTING.md. +test("aborting before the dispatch task rejects without starting the callback", async ({ + page, +}) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + let executions = 0; + await context.registerTool({ + name: "x", + description: "X", + execute() { + executions++; + }, + }); + const [tool] = await context.getTools(); + const controller = new AbortController(); + const pending = context + .executeTool(tool, {}, { signal: controller.signal }) + .catch((error) => error); + controller.abort("before dispatch"); + const reason = await pending; + await context.getTools(); + return [executions, reason]; + }), + ).toEqual([0, "before dispatch"]); +}); + +test("execution converts every descriptor member before invoking the tool", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const result = await page.evaluate(async () => { + const context = document.modelContext!; + let calls = 0; + await context.registerTool({ + name: "descriptor", + description: "Descriptor conversion", + execute() { + return ++calls; + }, + }); + const [tool] = await context.getTools(); + const errors = []; + const fakeWindow = {}; + Object.assign(fakeWindow, { window: fakeWindow }); + const invalid: unknown[] = [ + { ...tool, title: Symbol() }, + { ...tool, inputSchema: 1 }, + { ...tool, annotations: 1 }, + { ...tool, window: fakeWindow }, + { ...tool, window: null }, + ]; + for (const descriptor of invalid) { + try { + // @ts-expect-error Exercise invalid JavaScript descriptor members. + await context.executeTool(descriptor, {}); + errors.push("resolved"); + } catch (error) { + if (!(error instanceof Error)) throw error; + errors.push(error.name); + } + } + const invalidCalls = calls; + const reads: string[] = []; + const descriptor = { + get annotations() { + reads.push("annotations"); + return { + get consequentialHint() { + reads.push("consequentialHint"); + return false; + }, + get readOnlyHint() { + reads.push("readOnlyHint"); + return false; + }, + get untrustedContentHint() { + reads.push("untrustedContentHint"); + return false; + }, + }; + }, + get description() { + reads.push("description"); + return tool.description; + }, + get inputSchema() { + reads.push("inputSchema"); + return {}; + }, + get name() { + reads.push("name"); + return tool.name; + }, + get origin() { + reads.push("origin"); + return tool.origin; + }, + get title() { + reads.push("title"); + return "Descriptor"; + }, + get window() { + reads.push("window"); + return tool.window; + }, + }; + await context.executeTool(descriptor, {}); + return { errors, invalidCalls, reads, calls }; + }); + expect(result).toEqual({ + errors: ["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], + invalidCalls: 0, + reads: [ + "annotations", + "consequentialHint", + "readOnlyHint", + "untrustedContentHint", + "description", + "inputSchema", + "name", + "origin", + "title", + "window", + ], + calls: 1, + }); +}); + + +test("a tool belonging to another window cannot be executed", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + await context.registerTool({ name: "local", description: "Local", execute: () => null }); + const [tool] = await context.getTools(); + const iframe = document.createElement("iframe"); + iframe.src = "/app"; + await new Promise((resolve) => { + iframe.onload = () => resolve(); + document.body.append(iframe); + }); + try { + await context.executeTool({ ...tool, window: iframe.contentWindow! }, {}); + return "resolved"; + } catch (error) { + if (!(error instanceof Error)) throw error; + return error.name; + } + }), + ).toBe("UnknownError"); +}); + +test("descriptor origins are parsed, and a mismatch fails like a missing tool", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + await context.registerTool({ name: "x", description: "X", execute: () => null }); + const [tool] = await context.getTools(); + const results: [string, string][] = []; + for (const [label, origin] of [ + ["unparseable", "not a url"], + ["opaque", "data:,x"], + ["another origin", "https://other.test"], + ]) { + try { + await context.executeTool({ ...tool, origin }, {}); + results.push([label, "resolved"]); + } catch (error) { + if (!(error instanceof Error)) throw error; + results.push([label, error.name]); + } + } + return results; + }), + ).toEqual([ + ["unparseable", "NotSupportedError"], + ["opaque", "NotSupportedError"], + ["another origin", "UnknownError"], + ]); +}); + +test("a signal option that is not an AbortSignal is rejected", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + await context.registerTool({ name: "x", description: "X", execute: () => null }); + const [tool] = await context.getTools(); + const results: [string, string][] = []; + for (const signal of [1, {}, null, "abort"] as unknown[]) { + const label = JSON.stringify(signal)!; + try { + // @ts-expect-error Exercise invalid JavaScript callers at the Web IDL boundary. + await context.executeTool(tool, {}, { signal }); + results.push([label, "resolved"]); + } catch (error) { + if (!(error instanceof Error)) throw error; + results.push([label, error.name]); + } + } + return results; + }), + ).toEqual([ + ["1", "TypeError"], + ["{}", "TypeError"], + ["null", "TypeError"], + ['"abort"', "TypeError"], + ]); +}); + +test("unregistering before dispatch rejects without running the callback", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + const registration = new AbortController(); + let executions = 0; + await context.registerTool( + { name: "x", description: "X", execute: () => ++executions }, + { signal: registration.signal }, + ); + const [tool] = await context.getTools(); + const pending = context.executeTool(tool, {}).catch((error: Error) => error.name); + registration.abort(); + return [await pending, executions]; + }), + ).toEqual(["UnknownError", 0]); +}); + +test("input that serializes to a non-object rejects before the callback runs", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + expect( + await page.evaluate(async () => { + const context = document.modelContext!; + let executions = 0; + await context.registerTool({ + name: "x", + description: "X", + execute: () => ++executions, + }); + const [tool] = await context.getTools(); + const name = await context.executeTool(tool, { toJSON: () => 5 }).then( + () => "resolved", + (error: Error) => error.name, + ); + return [name, executions]; + }), + ).toEqual(["UnknownError", 0]); +}); diff --git a/fixtures/app.js b/fixtures/app.js index c078bb1..c8b01a9 100644 --- a/fixtures/app.js +++ b/fixtures/app.js @@ -31,4 +31,14 @@ element("unregister").onclick = async () => { await context.getTools(); element("status").textContent = "unregistered"; }; +element("execute").onclick = async () => { + try { + const [tool] = await context.getTools(); + element("result").textContent = await context.executeTool(tool, { + amount: Number(element("amount").value), + }); + } catch (error) { + element("result").textContent = error.name; + } +}; await register(); diff --git a/fixtures/server.mjs b/fixtures/server.mjs index 18e5252..495fbf3 100644 --- a/fixtures/server.mjs +++ b/fixtures/server.mjs @@ -3,8 +3,10 @@ import { readFile } from "node:fs/promises"; const app = `WebMCP counter

Counter

0 + -loading`; + +loading`; createServer(async (request, response) => { const path = new URL(request.url, "http://localhost").pathname; diff --git a/index.test-d.ts b/index.test-d.ts index af01976..da7bcf2 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -14,8 +14,13 @@ if (document.modelContext) { return { name, aborted, invalid }; }, }); - const tools: WebMCP.RegisteredTool[] = await context.getTools(); - // @ts-expect-error Execution is deferred to a separate change. - void context.executeTool; - void tools; + const [tool] = await context.getTools(); + const result: string = await context.executeTool( + tool, + {}, + { signal: new AbortController().signal }, + ); + // @ts-expect-error The current draft accepts objects, not serialized JSON. + context.executeTool(tool, "{}"); + void result; } diff --git a/index.test.ts b/index.test.ts index 875573f..368cb78 100644 --- a/index.test.ts +++ b/index.test.ts @@ -18,6 +18,11 @@ test("every operation rejects when the server opts out of origin-keyed agent clu for (const operation of [ () => context.getTools(), () => context.registerTool({ name: "x", description: "X", execute: () => null }), + () => + context.executeTool( + { name: "x", title: "", description: "X", window, origin: location.origin }, + {}, + ), ]) { try { await operation(); @@ -29,7 +34,7 @@ test("every operation rejects when the server opts out of origin-keyed agent clu } return errors; }), - ).toEqual(["SecurityError", "SecurityError"]); + ).toEqual(["SecurityError", "SecurityError", "SecurityError"]); }); test("only potentially trustworthy origins reach the cross-document refusal", async ({ page }) => { @@ -250,6 +255,7 @@ test("operations on a real detached frame reject in the frame's realm", async ({ document.body.append(iframe); await loaded; const context = iframe.contentDocument!.modelContext!; + const tool = (await context.getTools())[0]; const FrameException = iframe.contentDocument!.defaultView!.DOMException; iframe.remove(); const errors = []; @@ -257,6 +263,7 @@ test("operations on a real detached frame reject in the frame's realm", async ({ () => context.getTools(), () => context.registerTool({ name: "detached", description: "Detached", execute: () => null }), + () => context.executeTool(tool, {}), ]) { try { await operation(); @@ -267,7 +274,7 @@ test("operations on a real detached frame reject in the frame's realm", async ({ } return errors; }); - expect(frame).toEqual(["InvalidStateError", "InvalidStateError"]); + expect(frame).toEqual(["InvalidStateError", "InvalidStateError", "InvalidStateError"]); }); test("installs once, exposes only standard members, and keeps document identity", async ({ @@ -308,14 +315,14 @@ test("installs once, exposes only standard members, and keeps document identity" writable: descriptor.set !== undefined, alias: "modelContext" in navigator, testing: "modelContextTesting" in navigator, - lengths: [context.registerTool.length, context.getTools.length], + lengths: [context.registerTool.length, context.getTools.length, context.executeTool.length], constructionError, getterErrors, }; }); expect(result).toEqual({ same: true, - members: ["getTools", "ontoolchange", "registerTool"], + members: ["executeTool", "getTools", "ontoolchange", "registerTool"], own: [], brand: "[object ModelContext]", instance: true, @@ -324,7 +331,7 @@ test("installs once, exposes only standard members, and keeps document identity" writable: false, alias: false, testing: false, - lengths: [1, 0], + lengths: [1, 0, 1], constructionError: "TypeError", getterErrors: ["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], }); @@ -450,7 +457,7 @@ test("rejects invalid descriptors, duplicates and unserializable schemas", async expect(registered).toEqual(["valid"]); }); -test("coerces registration dictionary members", async ({ page }) => { +test("uses dictionary coercion without retaining or binding the tool object", async ({ page }) => { await page.addScriptTag({ url: "/auto.js" }); const result = await page.evaluate(async () => { "use strict"; @@ -460,7 +467,9 @@ test("coerces registration dictionary members", async ({ page }) => { title: "\ud800", description: true, annotations: { readOnlyHint: 1 }, - execute: () => null, + execute() { + return this === undefined; + }, }; // @ts-expect-error Web IDL coerces the deliberately non-string fields. await context.registerTool(descriptor); @@ -470,6 +479,7 @@ test("coerces registration dictionary members", async ({ page }) => { title: tool.title, description: tool.description, annotations: tool.annotations, + result: await context.executeTool(tool, {}), }; }); expect(result).toEqual({ @@ -477,6 +487,7 @@ test("coerces registration dictionary members", async ({ page }) => { title: "\ufffd", description: "true", annotations: { consequentialHint: false, readOnlyHint: true, untrustedContentHint: false }, + result: "true", }); }); @@ -574,7 +585,12 @@ test("validates origins and refuses cross-document exposure", async ({ page }) = } return errors; }), - ).toEqual(["SecurityError", "SecurityError", "NotSupportedError", "NotSupportedError"]); + ).toEqual([ + "SecurityError", + "SecurityError", + "NotSupportedError", + "NotSupportedError", + ]); }); test("inactive documents get their own context but cannot register tools", async ({ page }) => { diff --git a/index.ts b/index.ts index 5d179ff..8ed3932 100644 --- a/index.ts +++ b/index.ts @@ -6,8 +6,27 @@ export type { WebMCP } from "webmcp-types"; * SPDX-License-Identifier: MIT */ -// Capture the constructor before a detached WindowProxy stops exposing it. +// Mirrors webmachinelearning/webmcp-types#3; delete once that ships in a release. +declare global { + namespace WebMCP { + interface ModelContextExecuteToolOptions { + signal?: AbortSignal; + } + interface ModelContext { + executeTool( + tool: RegisteredTool, + inputObject?: object, + options?: ModelContextExecuteToolOptions, + ): Promise; + } + } +} + +// Capture native bindings before a detached WindowProxy stops exposing them. HTML declares +// `window` as [LegacyUnforgeable], so its getter is an own property of every Window; a realm +// without one has no Document either, and `installWebMCP` exits early there. const NativeDOMException = globalThis.DOMException; +const windowGetter = Object.getOwnPropertyDescriptor(globalThis, "window")?.get; interface Tool { metadata: Omit; @@ -156,7 +175,7 @@ function activeView(owner: Document): Window { } // Timers approximate the unavailable WebMCP task source; exact -// inter-source ordering requires native support. +// inter-source ordering and document-navigation semantics require native support. function queueTask(callback: () => void): void { setTimeout(callback, 0); } @@ -199,7 +218,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { const description = domString(required(descriptor.description, "description")); const callback = required(descriptor.execute, "execute"); if (typeof callback !== "function") throw new TypeError("execute must be a function"); - // SAFETY: Web IDL requires callability only. + // SAFETY: Web IDL requires callability only; arguments and results are converted at invocation. const execute = callback as WebMCP.ToolExecuteCallback; const inputSchema = descriptor.inputSchema; if (inputSchema !== undefined && !isObject(inputSchema)) @@ -275,6 +294,112 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); return new Promise((resolve) => queueTask(() => resolve(tools))); } + + async executeTool( + tool: WebMCP.RegisteredTool, + inputObject: object | undefined = undefined, + options: WebMCP.ModelContextExecuteToolOptions = {}, + ): Promise { + // Web IDL converts every member of RegisteredTool, in lexicographical order, before the + // algorithm runs, so the conversions whose results are unused below are kept for their + // observable effects: reading the caller's getters, and throwing TypeError. + const descriptor = dictionary(tool); + toolAnnotations(descriptor.annotations); + domString(required(descriptor.description, "description")); + const inputSchema = descriptor.inputSchema; + if (inputSchema !== undefined && !isObject(inputSchema)) + throw new TypeError("inputSchema must be an object"); + const name = domString(required(descriptor.name, "name")); + const origin = domString(required(descriptor.origin, "origin")).toWellFormed(); + const title = descriptor.title; + if (title !== undefined) domString(title); + const target = required(descriptor.window, "window"); + if (!isObject(target)) throw new TypeError("window must be a Window"); + // SAFETY: the native getter performs the Window brand check, including for cross-origin + // WindowProxy objects, and throws for anything else. Its value is unused. + windowGetter!.call(target); + const signal = signalOption(dictionary(options).signal); + activeView(this.#owner); + let expectedOrigin = "null"; + try { + expectedOrigin = new URL(origin).origin; + } catch { + // An unparseable origin falls through to the opaque check below. + } + // `URL.origin` serializes an opaque origin as "null", which no document's origin matches. + if (expectedOrigin === "null") { + throw new NativeDOMException("Invalid or opaque origin", "NotSupportedError"); + } + if (!isObject(inputObject)) throw new TypeError("inputObject must be an object"); + const input = serialize(inputObject); + signal?.throwIfAborted(); + if (target !== this.#owner.defaultView) { + // The draft rejects a target in another traversable with UnknownError, and reports a + // target document that has no such tool the same way. Routing to another document in this + // traversable needs native WebMCP, so both arrive here indistinguishably. + throw new NativeDOMException("Tool execution failed", "UnknownError"); + } + + return new Promise((resolve, reject) => { + const controller = new AbortController(); + let settled = false; + let callbackSettled = false; + // Returns true to the first caller only; whoever wins owns settling the promise. + const claimSettlement = (): boolean => { + if (settled) return false; + settled = true; + signal?.removeEventListener("abort", abort); + return true; + }; + const abort = (): void => { + if (!claimSettlement()) return; + reject(signal!.reason); + // The callback receives a fresh signal and a default AbortError, while the caller + // receives its own abort reason. Aborting a task later lets the caller's rejection + // arrive first, and leaves a callback that has already settled alone. + queueTask(() => { + if (!callbackSettled) controller.abort(); + }); + }; + const fail = (): void => { + callbackSettled = true; + queueTask(() => { + if (claimSettlement()) + reject(new NativeDOMException("Tool execution failed", "UnknownError")); + }); + }; + const complete = (value: unknown): void => { + callbackSettled = true; + // Checked before serializing: a cancelled call must not run the author's toJSON. + if (settled) return; + try { + const result = serialize(value); + queueTask(() => { + if (claimSettlement()) resolve(result); + }); + } catch { + fail(); + } + }; + signal?.addEventListener("abort", abort, { once: true }); + queueTask(() => { + if (settled) return; + try { + // Every dispatch failure is indistinguishable to the caller by design, so each bare + // throw funnels into fail() and the draft's UnknownError. + const view = activeView(this.#owner); + const entry = this.#tools.get(name); + if (!entry || expectedOrigin !== view.origin) throw new Error(); + const args: unknown = JSON.parse(input); + if (!isObject(args)) throw new Error(); + const { execute } = entry; + Promise.resolve(execute(args, { signal: controller.signal })).then(complete, fail); + } catch { + fail(); + } + }); + }); + } } const contexts = new WeakMap(); @@ -299,6 +424,7 @@ Object.defineProperties(ModelContextPolyfill.prototype, { // Class members are non-enumerable; Web IDL interface members are enumerable. registerTool: { enumerable: true }, getTools: { enumerable: true }, + executeTool: { enumerable: true }, ontoolchange: { enumerable: true }, }); diff --git a/package.test.mjs b/package.test.mjs index 7af0308..0b4dba2 100644 --- a/package.test.mjs +++ b/package.test.mjs @@ -64,9 +64,12 @@ try { return { count }; }, }); - async function discover(context: WebMCP.ModelContext) { - const tools: WebMCP.RegisteredTool[] = await context.getTools(); - return tools; + async function invoke(context: WebMCP.ModelContext) { + const [tool] = await context.getTools(); + const result: string = await context.executeTool(tool, { count: 1 }); + // @ts-expect-error legacy JSON-string input is not supported + await context.executeTool(tool, '{}'); + return result; } `, ); diff --git a/playwright.config.ts b/playwright.config.ts index 5e731e4..3c6d3ec 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ - testMatch: /(?:index|app)\.test\.ts$/, + testMatch: /(?:index|execute|app)\.test\.ts$/, forbidOnly: !!process.env.CI, fullyParallel: true, workers: 3, diff --git a/tsconfig.test.json b/tsconfig.test.json index 6fa915b..058e7d9 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,6 +4,7 @@ "files": [ "index.test-d.ts", "index.test.ts", + "execute.test.ts", "app.test.ts", "native.test.ts", "playwright.config.ts" diff --git a/wpt-metadata/webmcp/imperative/executeTool-error-window-onerror.https.html.ini b/wpt-metadata/webmcp/imperative/executeTool-error-window-onerror.https.html.ini new file mode 100644 index 0000000..a0fff5f --- /dev/null +++ b/wpt-metadata/webmcp/imperative/executeTool-error-window-onerror.https.html.ini @@ -0,0 +1,6 @@ +# Draft differences and matching local coverage: TESTING.md. +[executeTool-error-window-onerror.https.html] + [Failed tool execution does not trigger window.onerror] + expected: FAIL + [Tool execution returning circular object rejects and does not trigger window.onerror] + expected: FAIL diff --git a/wpt-metadata/webmcp/imperative/executeTool-invalid-dictionary.https.html.ini b/wpt-metadata/webmcp/imperative/executeTool-invalid-dictionary.https.html.ini new file mode 100644 index 0000000..7e3f29e --- /dev/null +++ b/wpt-metadata/webmcp/imperative/executeTool-invalid-dictionary.https.html.ini @@ -0,0 +1,4 @@ +# Draft differences and matching local coverage: TESTING.md. +[executeTool-invalid-dictionary.https.html] + [executeTool() rejects with UnknownError when a non-existent tool object is supplied] + expected: FAIL diff --git a/wpt-metadata/webmcp/imperative/executeTool-unregister-resolution-race.https.html.ini b/wpt-metadata/webmcp/imperative/executeTool-unregister-resolution-race.https.html.ini new file mode 100644 index 0000000..054efdf --- /dev/null +++ b/wpt-metadata/webmcp/imperative/executeTool-unregister-resolution-race.https.html.ini @@ -0,0 +1,4 @@ +# Draft differences and matching local coverage: TESTING.md. +[executeTool-unregister-resolution-race.https.html] + [executeTool resolves successfully even when tool is unregistered synchronously after its promise resolves] + expected: FAIL diff --git a/wpt-metadata/webmcp/imperative/object-arguments.https.html.ini b/wpt-metadata/webmcp/imperative/object-arguments.https.html.ini new file mode 100644 index 0000000..59b25bc --- /dev/null +++ b/wpt-metadata/webmcp/imperative/object-arguments.https.html.ini @@ -0,0 +1,4 @@ +# Draft differences and matching local coverage: TESTING.md. +[object-arguments.https.html] + [tool execution requires a JSON Object input argument; and Arrays are Objects] + expected: FAIL diff --git a/wpt.mjs b/wpt.mjs index 3af4b0a..9ac8910 100644 --- a/wpt.mjs +++ b/wpt.mjs @@ -11,7 +11,7 @@ if (!root || !chrome) const revision = "1a21db90adf8a264370ad806ed761f39e1d435a0"; // Pinned together with `revision` and `tests`; keep TESTING.md in sync. -const EXPECTED_ASSERTIONS = 27; +const EXPECTED_ASSERTIONS = 56; const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }); if (head.error) throw head.error; if (head.status !== 0 || head.stdout.trim() !== revision) { @@ -23,8 +23,8 @@ if (clean.status !== 0) { throw new Error("WPT has tracked changes; restore the pinned sources before running conformance"); } -// Unmodified upstream registration/discovery tests. Execution and full IDL -// coverage belong to the executeTool follow-up. +// Unmodified upstream files. Draft disagreements are tracked by exact subtest +// in wpt-metadata, never by patching tests or silently skipping failures. const tests = [ "imperative/register_tool_name_validation.https.html", "imperative/register_tool_signal.https.html", @@ -36,9 +36,14 @@ const tests = [ "imperative/getTools-imperative-schema.https.html", "imperative/model_context.https.html", "imperative/non-secure.html", + "idlharness.https.window.html", "imperative/register-tool-title.https.html", "imperative/register_tool_with_empty_annotation.https.html", "imperative/getTools-imperative-annotations.https.html", + "imperative/executeTool-invalid-dictionary.https.html", + "imperative/executeTool-error-window-onerror.https.html", + "imperative/executeTool-unregister-resolution-race.https.html", + "imperative/object-arguments.https.html", ]; const report = fileURLToPath(new URL("./wpt-results/report.json", import.meta.url)); mkdirSync(dirname(report), { recursive: true }); @@ -70,6 +75,8 @@ const result = spawnSync( fileURLToPath(new URL("./dist/polyfill.js", import.meta.url)), "--manifest", resolve(root, "MANIFEST.json"), + "--metadata", + fileURLToPath(new URL("./wpt-metadata", import.meta.url)), "--log-mach=-", "--log-wptreport", report, From 17d250748591dadc1558659165fbae4e8ca4f403 Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Wed, 16 Sep 2026 20:52:13 -0700 Subject: [PATCH 3/9] refactor(polyfill): prepare registration and execution for review Use released webmcp-types 0.1.8 and remove the temporary executeTool augmentation. Shorten comments and test setup while preserving Web IDL conversion, cancellation, and native-installation behavior. Run every pinned WebMCP testharness file with standard WPT expectations instead of a selected list. Record failures, timeouts, and blocked subtests separately so the supported scope is visible. Apply the review's fixture, header, and Chrome flag corrections. Validate blob URLs using their inherited origin; a browser regression demonstrates the former SecurityError for trustworthy blob origins. Validated with pnpm test (100 browser tests), pnpm test:package, and pnpm test:wpt (58 files; all results match their explicit expectations). References: https://github.com/webmachinelearning/webmcp-polyfill/pull/1 https://github.com/webmachinelearning/webmcp-polyfill/pull/2 https://github.com/webmachinelearning/webmcp-types/releases/tag/v0.1.8 https://url.spec.whatwg.org/#concept-url-origin --- AGENTS.md | 3 +- README.md | 12 +- TESTING.md | 245 ++++++++---------- execute.test.ts | 45 +--- fixtures/app.js | 3 +- index.test.ts | 15 +- index.ts | 141 +++------- package.json | 2 +- playwright.config.ts | 2 +- pnpm-lock.yaml | 10 +- .../duplicate-tool-name.https.html.ini | 7 + .../executeTool-abort.https.html.ini | 5 + ...respondWith-circular-object.https.html.ini | 5 + .../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 + .../select-multiple-events.https.html.ini | 5 + ...hange-on-attribute-mutation.https.html.ini | 5 + ...hange-on-control-add-remove.https.html.ini | 5 + .../toolchange-on-name-change.https.html.ini | 5 + ...register-during-executeTool.https.html.ini | 7 + .../detached-frame-executeTool.https.html.ini | 5 + .../detached-frame-getTools.https.html.ini | 4 + ...detached-frame-modelContext.https.html.ini | 4 + ...detached-frame-registerTool.https.html.ini | 4 + .../executeTool-abort.https.html.ini | 13 + .../executeTool-across-trees.https.html.ini | 4 + ...eTool-caller-navigate-abort.https.html.ini | 6 + ...teTool-error-window-onerror.https.html.ini | 2 +- ...cuteTool-invalid-dictionary.https.html.ini | 2 +- ...uteTool-signal-cross-origin.https.html.ini | 7 + ...ecuteTool-target-detachment.https.html.ini | 7 + ...ecuteTool-target-navigation.https.html.ini | 5 + ...uteTool-unauthorized-origin.https.html.ini | 4 + ...-unregister-resolution-race.https.html.ini | 2 +- ...xposedTo-cross-origin-child.https.html.ini | 13 + ...sedTo-defaults-cross-origin.https.html.ini | 11 + ...osedTo-defaults-same-origin.https.html.ini | 11 + .../exposedTo-invalid-origins.https.html.ini | 8 + ...exposedTo-multiple-children.https.html.ini | 4 + .../exposedTo-window-open.https.html.ini | 5 + .../getTools-filtering.https.html.ini | 7 + ...ial-about-blank-shared-tool.https.html.ini | 4 + .../object-arguments.https.html.ini | 2 +- .../permissions-policy.https.html.ini | 9 + ...ame-registerTool-regression.https.html.ini | 4 + ...register-during-executeTool.https.html.ini | 7 + wpt.mjs | 72 ++--- 51 files changed, 427 insertions(+), 353 deletions(-) create mode 100644 wpt-metadata/webmcp/declarative/duplicate-tool-name.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/executeTool-abort.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/executeTool-respondWith-circular-object.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/execute_tool_change_event.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/execute_tool_submit_from_js.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/form_removal_submit_crash.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/getTools-declarative-schema.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/no-frame-documents.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/opaque-origin-tools.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/select-multiple-events.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/toolchange-on-attribute-mutation.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/toolchange-on-control-add-remove.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/toolchange-on-name-change.https.html.ini create mode 100644 wpt-metadata/webmcp/declarative/unregister-during-executeTool.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/detached-frame-executeTool.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/detached-frame-getTools.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/detached-frame-modelContext.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/detached-frame-registerTool.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/executeTool-abort.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/executeTool-across-trees.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/executeTool-caller-navigate-abort.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/executeTool-signal-cross-origin.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/executeTool-target-detachment.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/executeTool-target-navigation.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/executeTool-unauthorized-origin.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/exposedTo-cross-origin-child.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/exposedTo-defaults-cross-origin.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/exposedTo-defaults-same-origin.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/exposedTo-invalid-origins.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/exposedTo-multiple-children.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/exposedTo-window-open.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/getTools-filtering.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/initial-about-blank-shared-tool.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/permissions-policy.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/same-origin-iframe-registerTool-regression.https.html.ini create mode 100644 wpt-metadata/webmcp/imperative/unregister-during-executeTool.https.html.ini diff --git a/AGENTS.md b/AGENTS.md index 4329c46..ae2d6be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,7 @@ # Working on the polyfill This package implements the document-local imperative WebMCP draft. Use the -official `webmcp-types` dependency; do not duplicate its declarations. Keep the -temporary `executeTool` augmentation only until the upstream package ships it. +official `webmcp-types` dependency; do not duplicate its declarations. No MCP server, transport, extension product, navigator aliases, or legacy API compatibility belongs here. diff --git a/README.md b/README.md index 1644019..6ff4335 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A polyfill for [WebMCP](https://webmachinelearning.github.io/webmcp/), with type ## Build -This package is under review. To try it locally, build this checkout with Node.js 24 and pnpm: +This package is in development. Build this checkout with Node.js 24 and pnpm: ```sh pnpm install @@ -15,7 +15,7 @@ Then install the checkout in your app with `pnpm add /path/to/webmcp-polyfill`. ## Usage -Serve your page over HTTPS with the `Origin-Agent-Cluster: ?1` response header. Localhost HTTP also works for development. +Serve your page over HTTPS, or localhost HTTP for development. Origin-keyed agent clustering must be enabled; current Chrome enables it by default, so an `Origin-Agent-Cluster: ?1` header is optional. Load the polyfill before registering tools: @@ -37,14 +37,14 @@ await context.registerTool( { signal: registration.signal }, ); -const [tool] = await context.getTools(); +const tool = (await context.getTools()).find((tool) => tool.name === "page-title")!; console.log(await context.executeTool(tool, {})); // {"title":"WebMCP demo"} // Remove the tool when it is no longer needed. registration.abort(); ``` -To install explicitly, import and call `installWebMCP` from `webmcp-polyfill`. Installation requires a secure browser context and leaves an existing `document.modelContext` unchanged, including partial native implementations. It affects only the realm that calls it, so each frame installs separately. +For explicit installation, import and call `installWebMCP` from `webmcp-polyfill`. It is safe to call repeatedly and during server-side rendering. Existing `document.modelContext` implementations are preserved, including partial native implementations. Each frame installs separately. ## Scope @@ -52,13 +52,13 @@ Tools stay in the current document. Cross-document tools, declarative forms, lif `executeTool()` accepts an object and returns a JSON-serialized result. Callbacks must validate their inputs; schema inference provides TypeScript checks only. -The implementation follows [draft source `cc45efc`](https://github.com/webmachinelearning/webmcp/blob/cc45efcaf0/index.bs). Of the 56 selected assertions of the [upstream WPT](https://github.com/web-platform-tests/wpt/tree/1a21db90adf8a264370ad806ed761f39e1d435a0/webmcp) at the pin, 51 pass and five fail where the pinned tests disagree with the draft's `executeTool()` input and result rules. [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) records each one and what the selection excludes. +The implementation tracks the [Community Group draft](https://webmachinelearning.github.io/webmcp/). [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) records the draft and WPT revisions, test coverage, and known limitations. The API tracks the draft. Breaking changes ship with notes: in minor releases while the version is 0.x, in majors after 1.0. ## Development -See [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) for browser setup, test commands, draft alignment, and known limitations. +See [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) for browser setup and test commands. ## License diff --git a/TESTING.md b/TESTING.md index 19e2b40..a9c4a2d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,8 +1,8 @@ # Testing and upstream tracking -## Run the browser suite +## Browser and package checks -Use Node.js 24, pnpm, and the Playwright browsers pinned by the lockfile: +Use Node.js 24 and pnpm: ```sh pnpm install --frozen-lockfile @@ -11,149 +11,110 @@ pnpm test pnpm test:package ``` -| Check | What it runs | -| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `index.test-d.ts` | TypeScript against the built package declarations, including upstream schema inference | -| `index.test.ts` | Built bundle served over HTTP in Chromium (native WebMCP disabled), Firefox, and WebKit; coercion, metadata, events, errors, detached documents | -| `execute.test.ts` | The same three engines: object input, JSON results, cancellation, concurrent calls, and dispatch-time failures | -| `app.test.ts` | A served application, real button interactions, callback side effects, invalid input, unregistration, and reload | -| `native.test.ts` | Real native Chromium registration, then polyfill loading; context and getter identities must survive | -| `pnpm test:package` | Packed tarball installed into a fresh consumer, public type imports, SSR-safe entry points, and package contents | -| `pnpm test:wpt` | Unmodified upstream WPT and IDL in real Chrome Canary, with native WebMCP disabled | - -The fixture server binds 127.0.0.1:8793 and sets the required `Origin-Agent-Cluster` -header; Playwright never reuses an existing server, so free that port first. -Loopback HTTP is a secure context; -the WPT `non-secure.html` case supplies the nonsecure-origin check. The browser -suite uses fresh contexts and real network responses, without route interception, -fake timers, DOM shims, or mocked tool callbacks. - -Playwright retains failure traces and an HTML report in `playwright-report/`. -Playwright drives its bundled Firefox for page tests. WebKit is additional engine -coverage, not a claim that Safari itself was tested. - -## Run upstream WPT - -Use Python 3.11+, Chrome Canary, and a WPT checkout at -`1a21db90adf8a264370ad806ed761f39e1d435a0`. A sparse checkout needs `common`, -`docs`, `interfaces`, `resources`, `tools`, and `webmcp` plus the root files. -The CI workflow contains the exact checkout recipe. On Ubuntu, CI installs an -[AppArmor profile](https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md) -for the downloaded Chrome binary so it can create its sandbox using user -namespaces. The profile applies only to that executable. +`pnpm test` runs lint, builds the bundle, checks TypeScript, and runs Playwright. +Tests load the built bundle from a real server in Chromium, Firefox, and WebKit. +Chromium runs with native WebMCP disabled; a separate native Chromium test checks +that installation preserves its context and registered tools. + +| File | Coverage | +| ------------------ | ------------------------------------------------------------------------------------------- | +| `index.test.ts` | Registration, discovery, conversion, metadata copies, events, abort, and detached documents | +| `execute.test.ts` | Object input, JSON results, cancellation, concurrent calls, and dispatch failures | +| `app.test.ts` | Button interactions, callback side effects, invalid input, unregistration, and reload | +| `native.test.ts` | Preservation of the native context, getter, and tools | +| `index.test-d.ts` | Published declarations and upstream schema inference | +| `package.test.mjs` | Packed consumer imports, type inference, SSR entry points, and package contents | + +The fixture server uses port 8793 and loopback HTTP, which is a secure context. +It sends `Origin-Agent-Cluster: ?1` for consistent setup and `?0` on the opt-out +fixture. The header is optional in current Chrome's default configuration. +Playwright requires a free port and retains failure traces. Its bundled WebKit +provides engine coverage; it is not Safari. + +## Upstream WPT + +Use Python 3.11+, Chrome Canary, and a clean WPT checkout at +[`1a21db90adf8a264370ad806ed761f39e1d435a0`](https://github.com/web-platform-tests/wpt/commit/1a21db90adf8a264370ad806ed761f39e1d435a0). +A sparse checkout needs `common`, `docs`, `interfaces`, `resources`, `tools`, +and `webmcp`, plus the root files. CI includes the checkout recipe. ```sh WPT_ROOT=../wpt CHROME_BIN=/path/to/chrome-canary pnpm test:wpt ``` `WPT_PYTHON` and `WPT_VENV` optionally select the interpreter and environment. -The runner checks the revision, rejects tracked source changes, requires all 18 selected files -to run exactly once, checks assertion counts, and retains `wpt-results/report.json` -with browser and upstream revisions. Runner errors, unexpected failures, and -unexpected passes fail the command, and nothing is retried. - -At this pin, 56 assertions run: **51 pass and five have explicit expected FAIL -metadata**. All 22 IDL assertions pass. API shape coverage does not prove runtime -defaults or complete conformance. - -### Known draft disagreements - -The [published draft](https://webmachinelearning.github.io/webmcp/#dom-modelcontext-executetool) -accepts optional `any inputObject`, rejects non-objects, and has no `{}` default. -It also JSON-serializes callback results, including strings. The pinned WPT -expects a default object and raw string results in the following cases: - -| Upstream file | Expected failures | Local coverage | -| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | -| `executeTool-invalid-dictionary.https.html` | Missing tool invoked without input expects `UnknownError`, rather than the draft's earlier input `TypeError` | Descriptor validation and input errors | -| `executeTool-error-window-onerror.https.html` | Both cases omit input and expect execution to start | Explicit object input, callback/serialization failures, and absence of page errors | -| `executeTool-unregister-resolution-race.https.html` | Invocation omits input | Unregistration during an invocation and its successful result | -| `object-arguments.https.html` | Expects an unquoted string result; also expects omitted input to become `{}` | Array/object inputs, rejected primitive inputs, and JSON result serialization | - -These are five assertions in four files, recorded individually in `wpt-metadata`. -The pin, [1a21db9](https://github.com/web-platform-tests/wpt/commit/1a21db90adf8a264370ad806ed761f39e1d435a0), -is the WPT export of the Chromium change for -[spec PR #246](https://github.com/webmachinelearning/webmcp/pull/246) and -[#251](https://github.com/webmachinelearning/webmcp/pull/251), and it rewrote all four of these -files. The draft's input and result rules have not changed since #251 merged, so these are not -stale tests awaiting an update: four of the assertions invoke `executeTool()` with no input, -which the draft rejects with a `TypeError` before it looks the tool up, and `object-arguments` -asserts an unquoted `"Success"` from a callback whose result the draft JSON-serializes. Re-read the live -draft before changing any expectation. An expected failure can stop at its first assertion, so -the local tests cover the behavior after that point. - -`webmcp-types` PR #3 and the augmentation in `index.ts` both declare `inputObject?: object`, -which is narrower than the IDL's `any`: omitting the argument type-checks and then rejects at -runtime, as the draft requires. - -### Excluded coverage - -Cross-document discovery/execution, frame-tree routing, navigation cancellation, -declarative forms, browser permissions integration, CSS states, and lifecycle -window events are outside this initial implementation. The pinned -`executeTool-abort.https.html` is excluded rather than carried as expected -failures: its second subtest waits forever on a `toolactivated` event that the -draft still leaves [unspecified](https://github.com/webmachinelearning/webmcp/issues/146), -so the file times out and its last three subtests never run at all. Expected-failure -metadata cannot express that, and cancellation is exercised directly in all three -engines instead. -`exposedTo-invalid-origins.https.html` is excluded for the `exposedTo` divergence -recorded below, not for cross-document routing. - -WPT's `--inject-script` modifies testharness pages; it does not install the bundle -in `/common/blank.html` helper frames. Detached-frame WPT therefore cannot run -unchanged in this lane. The local test serves an instrumented iframe, removes -it, and checks all three operations and exception realms. - -## Where to look when upstream changes - -| Source | Use | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| [Draft source and history](https://github.com/webmachinelearning/webmcp/commits/main/index.bs) | Normative algorithms, Web IDL, open issues; compare with the revision below | -| [WPT webmcp](https://github.com/web-platform-tests/wpt/tree/master/webmcp) and [results](https://wpt.fyi/results/webmcp) | Executable assertions and native cross-browser results; these results are not polyfill results | -| [WPT IDL](https://github.com/web-platform-tests/wpt/blob/master/interfaces/webmcp.idl) | Generated interface snapshot; it can lag the published draft | -| [Official types](https://github.com/webmachinelearning/webmcp-types) | Public declarations, schema inference, and pending API updates | -| [Blink script_tools](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/script_tools/) | Chromium IDL, implementation, tests, and commit-linked bugs | -| [Gecko source search](https://searchfox.org/mozilla-central/search?q=ModelContext) and [Mozilla position](https://github.com/mozilla/standards-positions/issues/1412) | Locate Firefox implementation work and discussion; a position is not evidence of shipped support | - -Reproduce a disagreement before changing code or expectations, and record what -changed in the draft, types, WPT, and browser implementation separately. - -## Lint - -`pnpm lint` runs Oxlint with correctness checks, shadowing checks, and a ban on -explicit `any`. Warnings fail the command, and `pnpm test` runs it before compiling. - -## Draft and types - -The implementation was compared with [draft source `cc45efc`](https://github.com/webmachinelearning/webmcp/blob/cc45efcaf0/index.bs). -It uses timers to queue tasks; JavaScript cannot reproduce the browser's WebMCP -task source, nor the draft's abort *algorithms*, which run before an abort event -rather than as a listener. One consequence is observable: a signal aborted before -the dispatch timer fires rejects the caller and never runs the callback, where the -draft dispatches to the target document and then cancels through the callback's own -signal. - -The runtime checks the `tools` Permissions Policy when the browser exposes it. -No engine lists `tools` in `permissionsPolicy.features()` today, so that branch -is currently inert and cross-origin frames are denied by the same-origin -fallback, which stands in for the feature's `self` default allowlist: it denies a -cross-origin frame the embedder allowed, and allows a same-origin frame the -embedder denied. Operations reject with `SecurityError` when `originAgentCluster` -is false, except in `file:` documents; where the property is absent the check is -skipped. Schema inference does not validate callback inputs at runtime. - -Non-empty `exposedTo` and `fromOrigins` reject with `NotSupportedError`. The -draft would validate the origins and then resolve, since a document-local -implementation has nowhere to expose a tool to; rejecting keeps the polyfill from -implying cross-document support it does not have. Origins are validated first, so -an untrustworthy one still fails with the `SecurityError` the draft requires. - -`webmcp-types@0.1.7` does not declare `executeTool()`. The method augmentation -in `index.ts` is temporary; remove it when [types PR #3](https://github.com/webmachinelearning/webmcp-types/pull/3) -is included in a release. - -To test types changes, keep `webmcp-types` beside this checkout and run -`pnpm link ../webmcp-types`, then `pnpm typecheck`. Restore the published -dependency with `pnpm install --force` before package checks. Do not commit -local dependency overrides. +On Ubuntu, CI installs an [AppArmor profile](https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md) +for Chrome to create its sandbox. + +The runner selects **every testharness test under `/webmcp`**, including +declarative and cross-document tests. Other WPT test types, such as crashtests, +are outside this lane. Native WebMCP is disabled and WPT injects the built +polyfill. Upstream test sources remain unchanged. + +`wpt-metadata/` contains standard WPT expectations with a reason for each +affected file. Unexpected failures and unexpected passes fail the command. +File and subtest counts catch missing coverage and early harness exits. +Results and browser details are written to `wpt-results/report.json`. + +### Recorded results + +Chrome Canary 156.0.8062.0 reports **58 files and 139 subtests**: + +| Subtest result | Count | +| ---------------- | ----: | +| PASS | 72 | +| Expected FAIL | 23 | +| Expected TIMEOUT | 25 | +| Expected NOTRUN | 19 | + +At the file level, 32 harnesses finish with OK, 25 time out, and one reports an +expected setup error. All 22 IDL subtests pass. These results do not establish +full WebMCP conformance. + +`NOTRUN` means an earlier subtest timed out before that subtest could run. +It is recorded separately from executed tests. The local browser suite covers +same-document cancellation and unregistration without lifecycle events. + +### Why tests fail + +- **Draft disagreements:** some pinned tests omit `executeTool()` input or expect + raw string results. The draft rejects non-object input, including omitted input, + and JSON-serializes callback results. Each affected subtest has an expectation. +- **Cross-document behavior:** tools and events stay in one document. Exposure, + frame-tree discovery, routing, and navigation cancellation are unsupported. +- **Declarative tools and lifecycle events:** form registration, CSS states, and + `toolactivated`/`toolcancel` events are not implemented. Tests waiting for + them time out; later subtests may not run. +- **Helper frames:** WPT injects into testharness pages, not initial `about:blank` + documents or `/common/blank.html`. Those frames have no polyfill. Local tests + install it in real served frames to exercise detachment. +- **Permissions Policy:** the polyfill checks the `tools` policy when the browser + exposes it. Otherwise, same-origin access approximates the default allowlist. + That fallback cannot honor an explicit denial or cross-origin permission. + +## Draft alignment and limitations + +The implementation was checked against +[draft source `df2d824`](https://github.com/webmachinelearning/webmcp/blob/df2d824e2cd2cbf8e15e25dad9dfe85d20e25082/index.bs). +Its change from the earlier `cc45efc` reference documents Permissions Policy +mitigation; API algorithms are unchanged. Public declarations come directly +from `webmcp-types@0.1.8`. + +Timers approximate the WebMCP task source. Exact task ordering, navigation +cleanup, and native abort algorithms cannot be reproduced. An invocation aborted +before dispatch never starts its callback; the draft dispatches and then cancels +through the callback's signal. + +Operations reject when `originAgentCluster` is false, except for `file:` +documents. Browsers without that property skip the check. Origin validation uses +URL parsing and scheme/host checks; browser-specific trusted schemes are not +recognized. Nonempty `exposedTo` and `fromOrigins` reject with +`NotSupportedError` after origin validation. Callbacks must validate their inputs. + +When updating the draft or WPT pin, compare the +[draft history](https://github.com/webmachinelearning/webmcp/commits/main/index.bs), +[upstream tests](https://github.com/web-platform-tests/wpt/tree/master/webmcp), and +[types](https://github.com/webmachinelearning/webmcp-types). +Use [Blink source](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/script_tools/) +for Chromium-specific details. Review every changed expectation, update both the +runner and CI pin, and record browser versions and results separately. diff --git a/execute.test.ts b/execute.test.ts index 7fe98e4..0582381 100644 --- a/execute.test.ts +++ b/execute.test.ts @@ -13,20 +13,15 @@ test("ignores late results after cancellation, including serialization side effe expect( await page.evaluate(async () => { const context = document.modelContext!; - let entered!: () => void; - const started = new Promise((resolve) => { - entered = resolve; - }); - let finish!: (value: object) => void; + const { promise: started, resolve: entered } = Promise.withResolvers(); + const { promise: completion, resolve: finish } = Promise.withResolvers(); let serialized = false; await context.registerTool({ name: "late", description: "Late", execute() { entered(); - return new Promise((resolve) => { - finish = resolve; - }); + return completion; }, }); const [tool] = await context.getTools(); @@ -55,10 +50,7 @@ test("concurrent calls to the same tool have independent cancellation", async ({ await page.evaluate(async () => { const context = document.modelContext!; const signals: AbortSignal[] = []; - let start!: () => void; - const bothStarted = new Promise((resolve) => { - start = resolve; - }); + const { promise: bothStarted, resolve: start } = Promise.withResolvers(); let finish!: (value: object) => void; await context.registerTool({ name: "concurrent", @@ -207,14 +199,8 @@ test("cancels the caller immediately and sends a default AbortError to the callb expect( await page.evaluate(async () => { const context = document.modelContext!; - let entered!: () => void; - const started = new Promise((resolve) => { - entered = resolve; - }); - let observed!: (reason: string) => void; - const callbackAborted = new Promise((resolve) => { - observed = resolve; - }); + const { promise: started, resolve: entered } = Promise.withResolvers(); + const { promise: callbackAborted, resolve: observed } = Promise.withResolvers(); await context.registerTool({ name: "pending", description: "Pending", @@ -251,11 +237,8 @@ test("unregistration leaves an already-running invocation alive", async ({ page await page.evaluate(async () => { const context = document.modelContext!; const registration = new AbortController(); - let entered!: () => void; - const started = new Promise((resolve) => { - entered = resolve; - }); - let complete!: (value: string) => void; + const { promise: started, resolve: entered } = Promise.withResolvers(); + const { promise: completion, resolve: complete } = Promise.withResolvers(); let callbackSignal!: AbortSignal; await context.registerTool( { @@ -264,9 +247,7 @@ test("unregistration leaves an already-running invocation alive", async ({ page execute(_input, { signal }) { callbackSignal = signal; entered(); - return new Promise((resolve) => { - complete = resolve; - }); + return completion; }, }, { signal: registration.signal }, @@ -282,8 +263,7 @@ test("unregistration leaves an already-running invocation alive", async ({ page ).toEqual({ count: 0, aborted: false, result: '"finished"' }); }); -// A polyfill divergence, not a draft requirement: the draft dispatches to the target -// document and cancels through the callback's own signal. See TESTING.md. +// Polyfill scheduling limitation; see TESTING.md. test("aborting before the dispatch task rejects without starting the callback", async ({ page, }) => { @@ -412,7 +392,6 @@ test("execution converts every descriptor member before invoking the tool", asyn }); }); - test("a tool belonging to another window cannot be executed", async ({ page }) => { await page.addScriptTag({ url: "/auto.js" }); expect( @@ -437,7 +416,9 @@ test("a tool belonging to another window cannot be executed", async ({ page }) = ).toBe("UnknownError"); }); -test("descriptor origins are parsed, and a mismatch fails like a missing tool", async ({ page }) => { +test("descriptor origins are parsed, and a mismatch fails like a missing tool", async ({ + page, +}) => { await page.addScriptTag({ url: "/auto.js" }); expect( await page.evaluate(async () => { diff --git a/fixtures/app.js b/fixtures/app.js index c8b01a9..72d4050 100644 --- a/fixtures/app.js +++ b/fixtures/app.js @@ -26,9 +26,8 @@ async function register() { element("status").textContent = "registered"; } element("register").onclick = register; -element("unregister").onclick = async () => { +element("unregister").onclick = () => { registration.abort(); - await context.getTools(); element("status").textContent = "unregistered"; }; element("execute").onclick = async () => { diff --git a/index.test.ts b/index.test.ts index 368cb78..d5d5630 100644 --- a/index.test.ts +++ b/index.test.ts @@ -47,6 +47,8 @@ test("only potentially trustworthy origins reach the cross-document refusal", as const results: [string, string][] = []; for (const origin of [ "https://example.test", + "blob:https://example.test/id", + "blob:http://localhost:8793/id", "wss://example.test", "file:///tmp", "http://127.0.0.1:8793", @@ -56,6 +58,7 @@ test("only potentially trustworthy origins reach the cross-document refusal", as "http://app.localhost:8793", "ws://localhost:8793", "http://example.test", + "blob:http://example.test/id", "ws://example.test", "ftp://localhost", "http://127.example.test", @@ -73,6 +76,8 @@ test("only potentially trustworthy origins reach the cross-document refusal", as }), ).toEqual([ ["https://example.test", "NotSupportedError"], + ["blob:https://example.test/id", "NotSupportedError"], + ["blob:http://localhost:8793/id", "NotSupportedError"], ["wss://example.test", "NotSupportedError"], ["file:///tmp", "NotSupportedError"], ["http://127.0.0.1:8793", "NotSupportedError"], @@ -82,6 +87,7 @@ test("only potentially trustworthy origins reach the cross-document refusal", as ["http://app.localhost:8793", "NotSupportedError"], ["ws://localhost:8793", "NotSupportedError"], ["http://example.test", "SecurityError"], + ["blob:http://example.test/id", "SecurityError"], ["ws://example.test", "SecurityError"], ["ftp://localhost", "SecurityError"], ["http://127.example.test", "SecurityError"], @@ -172,7 +178,7 @@ test("registration converts every dictionary member in Web IDL order", async ({ expect( await page.evaluate(async () => { const reads: string[] = []; - const record = (name: string, value: T): T => { + const record = (name: string, value: T): T => { reads.push(name); return value; }; @@ -585,12 +591,7 @@ test("validates origins and refuses cross-document exposure", async ({ page }) = } return errors; }), - ).toEqual([ - "SecurityError", - "SecurityError", - "NotSupportedError", - "NotSupportedError", - ]); + ).toEqual(["SecurityError", "SecurityError", "NotSupportedError", "NotSupportedError"]); }); test("inactive documents get their own context but cannot register tools", async ({ page }) => { diff --git a/index.ts b/index.ts index 8ed3932..d88e24c 100644 --- a/index.ts +++ b/index.ts @@ -6,35 +6,13 @@ export type { WebMCP } from "webmcp-types"; * SPDX-License-Identifier: MIT */ -// Mirrors webmachinelearning/webmcp-types#3; delete once that ships in a release. -declare global { - namespace WebMCP { - interface ModelContextExecuteToolOptions { - signal?: AbortSignal; - } - interface ModelContext { - executeTool( - tool: RegisteredTool, - inputObject?: object, - options?: ModelContextExecuteToolOptions, - ): Promise; - } - } -} - -// Capture native bindings before a detached WindowProxy stops exposing them. HTML declares -// `window` as [LegacyUnforgeable], so its getter is an own property of every Window; a realm -// without one has no Document either, and `installWebMCP` exits early there. +// Detached windows may stop exposing these bindings. const NativeDOMException = globalThis.DOMException; const windowGetter = Object.getOwnPropertyDescriptor(globalThis, "window")?.get; interface Tool { metadata: Omit; - /** - * Stored serialized rather than as an object: registration snapshots the schema so later - * mutation of the author's object is invisible, every `getTools()` hands back an independent - * deep copy, and a schema that cannot be serialized fails at registration time. - */ + // Snapshot at registration; parse a fresh copy for each discovery result. schema?: string; execute: WebMCP.ToolExecuteCallback; } @@ -47,7 +25,7 @@ function isObject(value: unknown): value is object { function dictionary(value: unknown): Record { if (value == null) return {}; if (!isObject(value)) throw new TypeError("Expected a dictionary"); - // SAFETY: Web IDL dictionaries admit any object; members remain unknown until converted. + // Dictionary members remain unknown until converted. return value as Record; } @@ -79,10 +57,7 @@ function serialize(value: unknown): string { } function signalOption(value: unknown): AbortSignal | undefined { - // Native composition validates the signal and still follows abort when an earlier listener - // on the caller's own signal calls stopImmediatePropagation. The draft registers an abort - // algorithm, which runs before the abort event; JavaScript cannot register one. - // SAFETY: AbortSignal.any performs the native brand check, including across realms. + // Native brand check across realms; composition survives stopImmediatePropagation(). return value === undefined ? undefined : AbortSignal.any([value as AbortSignal]); } @@ -98,26 +73,21 @@ function originSequence(value: unknown): string[] { ); } -// Every non-empty list is refused; the loop only decides which failure the caller sees, so an -// origin that native WebMCP would reject outright keeps failing with SecurityError instead of -// being masked by the polyfill's NotSupportedError. -// -// This approximates https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy on -// the URL's own scheme and host. The draft evaluates the URL's origin, so a scheme that -// inherits an inner origin (`blob:`) is judged on the outer scheme here; that only changes -// which of the two rejections a caller sees. +// Validate before refusing cross-document support, preserving SecurityError precedence. +// ponytail: scheme/host approximation; use native origin checks for full conformance. function rejectUnsupportedOrigins(origins: string[]): void { for (const origin of origins) { let url: URL; try { url = new URL(origin); + // blob: URLs inherit their origin's scheme and host. + if (url.origin !== "null") url = new URL(url.origin); } catch { throw new NativeDOMException("Invalid origin", "SecurityError"); } const local = url.hostname === "[::1]" || - // Loopback is a CIDR match on 127.0.0.0/8, not a prefix: the URL parser canonicalizes - // every numeric form to dotted-quad, so "127.example.test" is a domain, not loopback. + // URL canonicalizes numeric hosts; exclude domains such as 127.example.test. /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(url.hostname) || url.hostname === "localhost" || url.hostname === "localhost." || @@ -143,10 +113,7 @@ function activeView(owner: Document): Window { if (view.originAgentCluster === false && view.location.protocol !== "file:") { throw new NativeDOMException("An origin-keyed agent cluster is required", "SecurityError"); } - // Chromium is the only engine shipping either name, and it ships `featurePolicy` unflagged - // while `permissionsPolicy` is still behind an experimental flag, so the second branch is the - // one that runs today. `features()` is consulted first because a browser with the policy - // object but no `tools` feature would otherwise get a meaningless default from allowsFeature. + // Query the policy only if the browser recognizes the tools feature. const policy = ("permissionsPolicy" in owner ? owner.permissionsPolicy : undefined) ?? ("featurePolicy" in owner ? owner.featurePolicy : undefined); @@ -161,8 +128,7 @@ function activeView(owner: Document): Window { if (policy.allowsFeature("tools")) return view; throw new NativeDOMException("WebMCP is disabled by Permissions Policy", "NotAllowedError"); } - // With no usable policy surface the document cannot be asked whether WebMCP is permitted, so - // same-origin access stands in for the feature's `self` default allowlist. + // ponytail: same-origin fallback; native policy support is needed to honor allowlists. try { void view.parent.document; } catch { @@ -174,14 +140,12 @@ function activeView(owner: Document): Window { return view; } -// Timers approximate the unavailable WebMCP task source; exact -// inter-source ordering and document-navigation semantics require native support. +// ponytail: timer tasks; exact WebMCP scheduling and navigation cleanup need native support. function queueTask(callback: () => void): void { setTimeout(callback, 0); } -// Parameters carry `= …` defaults rather than `?` because Web IDL fixes each operation's -// `length` at its required-argument count, and only defaults reduce the length TypeScript emits. +// Default parameters preserve Web IDL's required-argument counts in function.length. class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { readonly #owner: Document; readonly #tools = new Map(); @@ -201,9 +165,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { set ontoolchange(handler: WebMCP.ModelContext["ontoolchange"]) { const next = typeof handler === "function" ? handler : null; - // Replacing one callable with another keeps the handler's place in the listener list, which - // is why the listener is not re-registered. Clearing it deactivates the handler and removes - // the listener, so setting a callable again appends a new one at the end. + // Replacing a handler preserves its listener position; clearing it removes that position. if (!this.#handler && next) this.addEventListener("toolchange", this.#listener); if (this.#handler && !next) this.removeEventListener("toolchange", this.#listener); this.#handler = next; @@ -218,7 +180,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { const description = domString(required(descriptor.description, "description")); const callback = required(descriptor.execute, "execute"); if (typeof callback !== "function") throw new TypeError("execute must be a function"); - // SAFETY: Web IDL requires callability only; arguments and results are converted at invocation. + // Callability is checked here; inputs and results are converted at invocation. const execute = callback as WebMCP.ToolExecuteCallback; const inputSchema = descriptor.inputSchema; if (inputSchema !== undefined && !isObject(inputSchema)) @@ -233,7 +195,10 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { const view = activeView(this.#owner); // Duplicate, then name, then description: the draft's order. if (this.#tools.has(name)) { - throw new NativeDOMException(`A tool named ${name} is already registered`, "InvalidStateError"); + throw new NativeDOMException( + `A tool named ${name} is already registered`, + "InvalidStateError", + ); } if (!/^[A-Za-z0-9_.-]{1,128}$/u.test(name)) { throw new NativeDOMException( @@ -255,9 +220,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { }; if (annotations !== undefined) entry.metadata.annotations = annotations; return new Promise((resolve, reject) => { - // One listener covers both roles: before `resolve` runs it rejects and rolls the - // registration back, and afterwards `reject` is inert so it only unregisters. Nothing else - // ever removes this entry, since registering the name again while it is held throws. + // Abort unregisters the tool and also rejects any pending registration. signal?.addEventListener( "abort", () => { @@ -281,16 +244,18 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { rejectUnsupportedOrigins(fromOrigins); const tools = [...this.#tools.values()] // Web IDL creates a dictionary's members in lexicographical order. - .map(({ metadata, schema }): WebMCP.RegisteredTool => ({ - ...(metadata.annotations && { annotations: { ...metadata.annotations } }), - description: metadata.description, - ...(schema !== undefined && { inputSchema: JSON.parse(schema) as object }), - name: metadata.name, - origin: metadata.origin, - title: metadata.title, - window: metadata.window, - })) - // The draft sorts by name, comparing code units, which is what `<` does on strings. + .map( + ({ metadata, schema }): WebMCP.RegisteredTool => ({ + ...(metadata.annotations && { annotations: { ...metadata.annotations } }), + description: metadata.description, + ...(schema !== undefined && { inputSchema: JSON.parse(schema) as object }), + name: metadata.name, + origin: metadata.origin, + title: metadata.title, + window: metadata.window, + }), + ) + // Code-unit order, not locale order. .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); return new Promise((resolve) => queueTask(() => resolve(tools))); } @@ -300,9 +265,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { inputObject: object | undefined = undefined, options: WebMCP.ModelContextExecuteToolOptions = {}, ): Promise { - // Web IDL converts every member of RegisteredTool, in lexicographical order, before the - // algorithm runs, so the conversions whose results are unused below are kept for their - // observable effects: reading the caller's getters, and throwing TypeError. + // Web IDL conversion reads every member in order, including fields unused by execution. const descriptor = dictionary(tool); toolAnnotations(descriptor.annotations); domString(required(descriptor.description, "description")); @@ -315,8 +278,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { if (title !== undefined) domString(title); const target = required(descriptor.window, "window"); if (!isObject(target)) throw new TypeError("window must be a Window"); - // SAFETY: the native getter performs the Window brand check, including for cross-origin - // WindowProxy objects, and throws for anything else. Its value is unused. + // The native getter checks the Window brand across realms. windowGetter!.call(target); const signal = signalOption(dictionary(options).signal); activeView(this.#owner); @@ -326,7 +288,6 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { } catch { // An unparseable origin falls through to the opaque check below. } - // `URL.origin` serializes an opaque origin as "null", which no document's origin matches. if (expectedOrigin === "null") { throw new NativeDOMException("Invalid or opaque origin", "NotSupportedError"); } @@ -334,9 +295,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { const input = serialize(inputObject); signal?.throwIfAborted(); if (target !== this.#owner.defaultView) { - // The draft rejects a target in another traversable with UnknownError, and reports a - // target document that has no such tool the same way. Routing to another document in this - // traversable needs native WebMCP, so both arrive here indistinguishably. + // Cross-document routing is unsupported; dispatch failures use UnknownError. throw new NativeDOMException("Tool execution failed", "UnknownError"); } @@ -344,7 +303,6 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { const controller = new AbortController(); let settled = false; let callbackSettled = false; - // Returns true to the first caller only; whoever wins owns settling the promise. const claimSettlement = (): boolean => { if (settled) return false; settled = true; @@ -354,9 +312,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { const abort = (): void => { if (!claimSettlement()) return; reject(signal!.reason); - // The callback receives a fresh signal and a default AbortError, while the caller - // receives its own abort reason. Aborting a task later lets the caller's rejection - // arrive first, and leaves a callback that has already settled alone. + // Reject the caller first; the running callback receives a default AbortError. queueTask(() => { if (!callbackSettled) controller.abort(); }); @@ -385,8 +341,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { queueTask(() => { if (settled) return; try { - // Every dispatch failure is indistinguishable to the caller by design, so each bare - // throw funnels into fail() and the draft's UnknownError. + // Dispatch and callback failures share the draft's UnknownError. const view = activeView(this.#owner); const entry = this.#tools.get(name); if (!entry || expectedOrigin !== view.origin) throw new Error(); @@ -404,14 +359,11 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { const contexts = new WeakMap(); -// Web IDL interface-object plumbing. A conforming `ModelContext` exposes a non-constructible -// interface object inheriting EventTarget, a prototype branded `[object ModelContext]`, and -// enumerable prototype members, none of which a plain class produces. +// Web IDL exposes a non-constructible interface with enumerable prototype members. const modelContextConstructor = function ModelContext(): never { throw new TypeError("Illegal constructor"); }; -// Not redundant with the function expression's name: the shipped bundle is minified and the -// minifier drops that binding, leaving `name` empty. The interface-shape test asserts it. +// Preserve the public name through minification. Object.defineProperty(modelContextConstructor, "name", { value: "ModelContext" }); Object.defineProperty(modelContextConstructor, "prototype", { value: ModelContextPolyfill.prototype, @@ -421,7 +373,6 @@ Object.setPrototypeOf(modelContextConstructor, EventTarget); Object.defineProperties(ModelContextPolyfill.prototype, { constructor: { value: modelContextConstructor, configurable: true, writable: true }, [Symbol.toStringTag]: { value: "ModelContext", configurable: true }, - // Class members are non-enumerable; Web IDL interface members are enumerable. registerTool: { enumerable: true }, getTools: { enumerable: true }, executeTool: { enumerable: true }, @@ -429,11 +380,8 @@ Object.defineProperties(ModelContextPolyfill.prototype, { }); /** - * Install the document-local WebMCP API when the current realm has no implementation. - * - * Does nothing without a `Document`, outside a secure context, or where `document.modelContext` - * already exists, so repeat calls are safe and a native implementation is never replaced. Only - * the calling realm is affected; each frame installs separately. + * Install WebMCP in this secure document's realm, preserving any existing implementation. + * Safe to call repeatedly or outside a browser. Each frame installs separately. * * @throws {TypeError} when the realm cannot be extended, rather than installing halfway. */ @@ -455,11 +403,7 @@ export function installWebMCP(): void { configurable: true, writable: true, }); - // The native `defaultView` getter rejects a foreign receiver with "Illegal invocation", which - // is the brand check Web IDL requires of `get modelContext`. Its value is unused. - // - // A concise method, not a function declaration: attribute getters are not constructible and - // have no `prototype` property, and only a method gets both. + // A method is non-constructible; defaultView supplies the native Document brand check. const { getModelContext } = { getModelContext(this: Document): WebMCP.ModelContext { defaultViewGetter.call(this); @@ -471,7 +415,6 @@ export function installWebMCP(): void { return context; }, }; - // A getter defined through a descriptor is named "get"; Web IDL requires "get modelContext". Object.defineProperty(getModelContext, "name", { value: "get modelContext" }); Object.defineProperty(prototype, "modelContext", { configurable: true, diff --git a/package.json b/package.json index c28027a..149588c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "lint": "oxlint --deny-warnings" }, "dependencies": { - "webmcp-types": "^0.1.7" + "webmcp-types": "^0.1.8" }, "devDependencies": { "@playwright/test": "^1.55.0", diff --git a/playwright.config.ts b/playwright.config.ts index 3c6d3ec..0b634cc 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ name: "chromium", use: { browserName: "chromium", - launchOptions: { args: ["--disable-features=WebMCP,WebMCPTesting"] }, + launchOptions: { args: ["--disable-features=WebMCP"] }, }, }, { name: "firefox", use: { browserName: "firefox" } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64d1b3c..f590c68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: webmcp-types: - specifier: ^0.1.7 - version: 0.1.7 + specifier: ^0.1.8 + version: 0.1.8 devDependencies: '@playwright/test': specifier: ^1.55.0 @@ -344,8 +344,8 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - webmcp-types@0.1.7: - resolution: {integrity: sha512-70YszESbCx+ozBejmBPTSc0hMyXim+/Kpj8UOZsHd36ANcYqIfkDbMBTTn5y+tQzQYUYN1x2R+5PseKJtVaLqA==} + webmcp-types@0.1.8: + resolution: {integrity: sha512-YtI13B7j9v8F4j+UyVr5ohg1GHl3IMu/Ozw08RqGh58Ft29rrr4Wh5W/Y3WfkARjchrcTpoOPxa939reQZ1Zzg==} snapshots: @@ -553,4 +553,4 @@ snapshots: undici-types@7.18.2: {} - webmcp-types@0.1.7: {} + webmcp-types@0.1.8: {} diff --git a/wpt-metadata/webmcp/declarative/duplicate-tool-name.https.html.ini b/wpt-metadata/webmcp/declarative/duplicate-tool-name.https.html.ini new file mode 100644 index 0000000..6796338 --- /dev/null +++ b/wpt-metadata/webmcp/declarative/duplicate-tool-name.https.html.ini @@ -0,0 +1,7 @@ +# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +[duplicate-tool-name.https.html] + expected: TIMEOUT + [Test that duplicate declarative tools with different descriptions do not crash and only the first is registered] + expected: TIMEOUT + [Test that duplicate declarative tools with identical descriptions do not crash and only the first is registered] + expected: NOTRUN diff --git a/wpt-metadata/webmcp/declarative/executeTool-abort.https.html.ini b/wpt-metadata/webmcp/declarative/executeTool-abort.https.html.ini new file mode 100644 index 0000000..58f3671 --- /dev/null +++ b/wpt-metadata/webmcp/declarative/executeTool-abort.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +[executeTool-abort.https.html] + expected: TIMEOUT + [executeTool signal successfully resets tool pseudo-classes] + expected: TIMEOUT diff --git a/wpt-metadata/webmcp/declarative/executeTool-respondWith-circular-object.https.html.ini b/wpt-metadata/webmcp/declarative/executeTool-respondWith-circular-object.https.html.ini new file mode 100644 index 0000000..800220a --- /dev/null +++ b/wpt-metadata/webmcp/declarative/executeTool-respondWith-circular-object.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +[executeTool-respondWith-circular-object.https.html] + expected: TIMEOUT + [Declarative tool executeTool() rejects when respondWith() receives a circular object] + expected: TIMEOUT diff --git a/wpt-metadata/webmcp/declarative/execute_tool_change_event.https.html.ini b/wpt-metadata/webmcp/declarative/execute_tool_change_event.https.html.ini new file mode 100644 index 0000000..fa421e1 --- /dev/null +++ b/wpt-metadata/webmcp/declarative/execute_tool_change_event.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +[execute_tool_change_event.https.html] + expected: TIMEOUT + [executeTool triggers input and change events] + expected: TIMEOUT diff --git a/wpt-metadata/webmcp/declarative/execute_tool_submit_from_js.https.html.ini b/wpt-metadata/webmcp/declarative/execute_tool_submit_from_js.https.html.ini new file mode 100644 index 0000000..96caad0 --- /dev/null +++ b/wpt-metadata/webmcp/declarative/execute_tool_submit_from_js.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +[execute_tool_submit_from_js.https.html] + expected: TIMEOUT + [executeTool resolves successfully when submit handler calls form.submit()] + expected: TIMEOUT diff --git a/wpt-metadata/webmcp/declarative/form_removal_submit_crash.https.html.ini b/wpt-metadata/webmcp/declarative/form_removal_submit_crash.https.html.ini new file mode 100644 index 0000000..27b3352 --- /dev/null +++ b/wpt-metadata/webmcp/declarative/form_removal_submit_crash.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +[form_removal_submit_crash.https.html] + expected: TIMEOUT + [Form removal on submit with respondWith does not crash the renderer] + expected: TIMEOUT diff --git a/wpt-metadata/webmcp/declarative/getTools-declarative-schema.https.html.ini b/wpt-metadata/webmcp/declarative/getTools-declarative-schema.https.html.ini new file mode 100644 index 0000000..f0e1ed0 --- /dev/null +++ b/wpt-metadata/webmcp/declarative/getTools-declarative-schema.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +[getTools-declarative-schema.https.html] + expected: TIMEOUT + [WebMCP: getTools() retrieves declarative schema at registration time] + expected: TIMEOUT diff --git a/wpt-metadata/webmcp/declarative/no-frame-documents.https.html.ini b/wpt-metadata/webmcp/declarative/no-frame-documents.https.html.ini new file mode 100644 index 0000000..e681a29 --- /dev/null +++ b/wpt-metadata/webmcp/declarative/no-frame-documents.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +[no-frame-documents.https.html] + expected: TIMEOUT + [Declarative tool in iframe removed after registration does not crash on attribute modification] + expected: TIMEOUT diff --git a/wpt-metadata/webmcp/declarative/opaque-origin-tools.https.html.ini b/wpt-metadata/webmcp/declarative/opaque-origin-tools.https.html.ini new file mode 100644 index 0000000..fce7c1f --- /dev/null +++ b/wpt-metadata/webmcp/declarative/opaque-origin-tools.https.html.ini @@ -0,0 +1,7 @@ +# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +[opaque-origin-tools.https.html] + expected: TIMEOUT + [An opaque origin document can register but not execute its own declarative tools] + expected: TIMEOUT + [executeTool() rejects synchronously for opaque origins] + expected: NOTRUN diff --git a/wpt-metadata/webmcp/declarative/select-multiple-events.https.html.ini b/wpt-metadata/webmcp/declarative/select-multiple-events.https.html.ini new file mode 100644 index 0000000..1a12472 --- /dev/null +++ b/wpt-metadata/webmcp/declarative/select-multiple-events.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +[select-multiple-events.https.html] + expected: TIMEOUT + [executeTool() on a - + + -loading`; +loading + + +`; createServer(async (request, response) => { const path = new URL(request.url, "http://localhost").pathname; response.setHeader("Origin-Agent-Cluster", path === "/no-cluster" ? "?0" : "?1"); response.setHeader("Cache-Control", "no-store"); + try { if (path === "/auto.js" || path === "/app.js") { + const scriptPath = path === "/auto.js" ? "../dist/polyfill.js" : "./app.js"; + const source = await readFile(new URL(scriptPath, import.meta.url)); response.setHeader("Content-Type", "text/javascript"); - response.end( - await readFile( - new URL(path === "/auto.js" ? "../dist/polyfill.js" : "./app.js", import.meta.url), - ), - ); + response.end(source); } else if (["/", "/health", "/no-cluster"].includes(path)) { response.setHeader("Content-Type", "text/html"); response.end("WebMCP test"); } else if (path === "/app") { response.setHeader("Content-Type", "text/html"); - response.end( - app + '', - ); + response.end(appHtml); } else { response.writeHead(404).end(); } diff --git a/index.test-d.ts b/index.test-d.ts index da7bcf2..cba6489 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -5,22 +5,28 @@ if (document.modelContext) { context.registerTool({ name: "greet", description: "Greet someone", - inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + inputSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, execute(input, { signal }) { const name: string = input.name; const aborted: boolean = signal.aborted; - // @ts-expect-error Upstream inference keeps name a string. + // @ts-expect-error The schema infers name as a string. const invalid: number = input.name; return { name, aborted, invalid }; }, }); + const [tool] = await context.getTools(); const result: string = await context.executeTool( tool, {}, { signal: new AbortController().signal }, ); + void result; + // @ts-expect-error The current draft accepts objects, not serialized JSON. context.executeTool(tool, "{}"); - void result; } diff --git a/index.test.ts b/index.test.ts index c5c9099..06573e1 100644 --- a/index.test.ts +++ b/index.test.ts @@ -27,7 +27,9 @@ test("every operation rejects when the server opts out of origin-keyed agent clu await operation(); errors.push("resolved"); } catch (error) { - if (!(error instanceof Error)) throw error; + if (!(error instanceof Error)) { + throw error; + } errors.push(error.name); } } @@ -67,7 +69,9 @@ test("only potentially trustworthy origins reach the cross-document refusal", as await context.getTools({ fromOrigins: [origin] }); results.push([origin, "resolved"]); } catch (error) { - if (!(error instanceof Error)) throw error; + if (!(error instanceof Error)) { + throw error; + } results.push([origin, error.name]); } } @@ -113,7 +117,9 @@ test("a cross-origin frame is denied without a native Permissions Policy", async await document.modelContext!.getTools(); return "resolved"; } catch (error) { - if (!(error instanceof Error)) throw error; + if (!(error instanceof Error)) { + throw error; + } return error.name; } }); @@ -134,7 +140,9 @@ test("a signal option that is not an AbortSignal is rejected", async ({ page }) await context.registerTool(tool, { signal }); results.push([label, "resolved"]); } catch (error) { - if (!(error instanceof Error)) throw error; + if (!(error instanceof Error)) { + throw error; + } results.push([label, error.name]); } } @@ -157,7 +165,7 @@ test("an already-aborted registration signal registers nothing and fires no tool const context = document.modelContext!; let changes = 0; context.ontoolchange = () => changes++; - let reason: unknown = "resolved"; + let reason: unknown; try { await context.registerTool( { name: "x", description: "X", execute: () => null }, @@ -167,7 +175,8 @@ test("an already-aborted registration signal registers nothing and fires no tool reason = error; } // getTools() resolves from a queued task, so awaiting it drains any pending toolchange. - return { reason, count: (await context.getTools()).length, changes }; + const tools = await context.getTools(); + return { reason, count: tools.length, changes }; }); expect(outcome).toEqual({ reason: "already aborted", count: 0, changes: 0 }); @@ -231,23 +240,48 @@ test("origin conversion gets an iterator only once and preserves its receiver", }) => { await page.addScriptTag({ url: "/auto.js" }); const outcome = await page.evaluate(async () => { - let reads = 0; - let receiver = false; - const origins = { - get [Symbol.iterator]() { - if (++reads > 1) throw new Error("Iterator getter was read twice"); - return function (this: typeof origins) { - receiver = this === origins; - return [][Symbol.iterator](); - }; - }, - }; - // @ts-expect-error Web IDL accepts iterables; the published types use arrays. - await document.modelContext!.getTools({ fromOrigins: origins }); - return { reads, receiver }; + const context = document.modelContext!; + const results = []; + for (const operation of ["registration", "discovery"]) { + let reads = 0; + let receiver = false; + const origins = { + get [Symbol.iterator]() { + reads++; + if (reads > 1) { + throw new Error("Iterator getter was read twice"); + } + const getIterator = function (this: typeof origins) { + receiver = this === origins; + return [][Symbol.iterator](); + }; + // Invocation must not consult the method's call, apply, bind, name, or length. + return new Proxy(getIterator, { + get() { + throw new Error("Iterator method properties must not be read"); + }, + }); + }, + }; + if (operation === "registration") { + // @ts-expect-error Web IDL accepts iterables; the published types use arrays. + await context.registerTool( + { name: "iterable", description: "Iterable origins", execute: () => null }, + { exposedTo: origins }, + ); + } else { + // @ts-expect-error Web IDL accepts iterables; the published types use arrays. + await context.getTools({ fromOrigins: origins }); + } + results.push({ operation, reads, receiver }); + } + return results; }); - expect(outcome).toEqual({ reads: 1, receiver: true }); + expect(outcome).toEqual([ + { operation: "registration", reads: 1, receiver: true }, + { operation: "discovery", reads: 1, receiver: true }, + ]); }); test("operations on a real detached frame reject in the frame's realm", async ({ page }) => { @@ -291,13 +325,17 @@ test("installs once, exposes only standard members, and keeps document identity" const result = await page.evaluate(() => { const context = document.modelContext!; const constructor = "ModelContext" in window ? window.ModelContext : undefined; - if (typeof constructor !== "function") throw new Error("ModelContext constructor is missing"); + if (typeof constructor !== "function") { + throw new Error("ModelContext constructor is missing"); + } const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!; let constructionError = ""; try { Reflect.construct(constructor, []); } catch (error) { - if (!(error instanceof Error)) throw error; + if (!(error instanceof Error)) { + throw error; + } constructionError = error.name; } const getterErrors = []; @@ -306,7 +344,9 @@ test("installs once, exposes only standard members, and keeps document identity" descriptor.get!.call(receiver); getterErrors.push("resolved"); } catch (error) { - if (!(error instanceof Error)) throw error; + if (!(error instanceof Error)) { + throw error; + } getterErrors.push(error.name); } } @@ -421,7 +461,7 @@ test("rejects invalid descriptors, duplicates and unserializable schemas", async await context.registerTool(good); const circular = {}; Object.assign(circular, { self: circular }); - const cases: [string, unknown][] = [ + const cases = [ ["null descriptor", null], ["execute is not callable", { ...good, execute: 1 }], ["name is a Symbol", { ...good, name: Symbol() }], @@ -436,7 +476,7 @@ test("rejects invalid descriptors, duplicates and unserializable schemas", async "inputSchema serializes to undefined", { ...good, name: "undefined", inputSchema: { toJSON: () => undefined } }, ], - ]; + ] as const; const results: [string, string][] = []; for (const [label, descriptor] of cases) { try { @@ -444,11 +484,14 @@ test("rejects invalid descriptors, duplicates and unserializable schemas", async await context.registerTool(descriptor); results.push([label, "resolved"]); } catch (error) { - if (!(error instanceof Error)) throw error; + if (!(error instanceof Error)) { + throw error; + } results.push([label, error.name]); } } - return { rejections: results, registered: (await context.getTools()).map((tool) => tool.name) }; + const tools = await context.getTools(); + return { rejections: results, registered: tools.map((tool) => tool.name) }; }); expect(rejections).toEqual([ ["null descriptor", "TypeError"], @@ -463,7 +506,6 @@ test("rejects invalid descriptors, duplicates and unserializable schemas", async ["inputSchema is circular", "TypeError"], ["inputSchema serializes to undefined", "TypeError"], ]); - // A rejected registration must not leave a tool behind. expect(registered).toEqual(["valid"]); }); @@ -564,7 +606,8 @@ test("rejects aborted registration and permits reusing its name", async ({ page controller.abort("cancel-registration"); const reason = await pending; await context.registerTool(descriptor); - return [reason, (await context.getTools()).length]; + const tools = await context.getTools(); + return [reason, tools.length]; }); expect(outcome).toEqual(["cancel-registration", 1]); @@ -579,7 +622,9 @@ test("validates origins and refuses cross-document exposure", async ({ page }) = try { await context.getTools({ fromOrigins: [origin] }); } catch (error) { - if (!(error instanceof Error)) throw error; + if (!(error instanceof Error)) { + throw error; + } errors.push(error.name); } } @@ -589,7 +634,9 @@ test("validates origins and refuses cross-document exposure", async ({ page }) = { exposedTo: ["https://other.test"] }, ); } catch (error) { - if (!(error instanceof Error)) throw error; + if (!(error instanceof Error)) { + throw error; + } errors.push(error.name); } return errors; @@ -612,7 +659,9 @@ test("inactive documents get their own context but cannot register tools", async try { await context.registerTool({ name: "x", description: "X", execute() {} }); } catch (error) { - if (!(error instanceof Error)) throw error; + if (!(error instanceof Error)) { + throw error; + } errorName = error.name; } return { @@ -643,7 +692,9 @@ test("a detached frame rejects even when its exception constructor was never rea await context.getTools(); return "resolved"; } catch (error) { - if (!error || typeof error !== "object" || !("name" in error)) throw error; + if (!error || typeof error !== "object" || !("name" in error)) { + throw error; + } return { name: error.name, type: Object.prototype.toString.call(error) }; } }); diff --git a/index.ts b/index.ts index 9e8910d..a38c513 100644 --- a/index.ts +++ b/index.ts @@ -396,7 +396,8 @@ function readInputSchema(value: unknown): object | undefined { function copyToolMetadata({ metadata, serializedSchema }: StoredTool): WebMCP.RegisteredTool { let inputSchema: object | undefined; if (serializedSchema !== undefined) { - // The draft returns parsed JSON unchanged; RegisteredTool declares it as an object. + // The draft preserves the JSON result, even if toJSON returned a primitive. + // RegisteredTool.inputSchema is typed as object; this cast bridges that mismatch. inputSchema = JSON.parse(serializedSchema) as object; } @@ -488,16 +489,13 @@ function readOriginSequence(value: unknown): string[] { if (!isObject(value)) { throw new TypeError("Origins must be a sequence"); } - const getIterator: unknown = Reflect.get(value, Symbol.iterator); + const getIterator = readDictionary(value)[Symbol.iterator]; if (typeof getIterator !== "function") { throw new TypeError("Origins must be a sequence"); } - // Web IDL gets the iterator method once and calls it with the original receiver. - const iterable = { - [Symbol.iterator]() { - return Reflect.apply(getIterator, value, []); - }, - }; + // Preserve the receiver without reading author-defined call or bind properties. + const iterate = Function.prototype.call.bind(getIterator, value); + const iterable = { [Symbol.iterator]: iterate }; return Array.from(iterable, (origin) => toDOMString(origin).toWellFormed()); } diff --git a/native.test.ts b/native.test.ts index 5d97aff..b7f69c2 100644 --- a/native.test.ts +++ b/native.test.ts @@ -5,29 +5,33 @@ test("loading the polyfill preserves the real native context and registered tool browser, }, testInfo) => { await page.goto("/"); - expect(await page.evaluate(() => typeof document.modelContext?.registerTool)).toBe("function"); + const registerToolType = await page.evaluate(() => typeof document.modelContext?.registerTool); + expect(registerToolType).toBe("function"); + const original = await page.evaluateHandle(async () => { - const context = document.modelContext; + const context = document.modelContext!; const getter = Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!.get; - await document.modelContext!.registerTool({ + await context.registerTool({ name: "native", description: "Native tool", execute: () => ({ native: true }), }); return { context, getter }; }); + await page.addScriptTag({ url: "/auto.js" }); - expect( - await page.evaluate( - async ({ context, getter }) => ({ - sameContext: document.modelContext === context, - sameGetter: - Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!.get === getter, - tools: (await document.modelContext!.getTools()).map((tool) => tool.name), - }), - original, - ), - ).toEqual({ sameContext: true, sameGetter: true, tools: ["native"] }); + + const outcome = await page.evaluate(async ({ context, getter }) => { + const currentGetter = Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!.get; + const tools = await document.modelContext!.getTools(); + return { + sameContext: document.modelContext === context, + sameGetter: currentGetter === getter, + tools: tools.map((tool) => tool.name), + }; + }, original); + expect(outcome).toEqual({ sameContext: true, sameGetter: true, tools: ["native"] }); + await testInfo.attach("browser.json", { body: JSON.stringify({ version: browser.version() }), contentType: "application/json", diff --git a/package.test.mjs b/package.test.mjs index 0b4dba2..72b668c 100644 --- a/package.test.mjs +++ b/package.test.mjs @@ -5,33 +5,37 @@ import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; -const consumer = mkdtempSync(join(tmpdir(), "webmcp-consumer-")); -const root = fileURLToPath(new URL(".", import.meta.url)); -function run(command, args, cwd = consumer) { - const result = spawnSync(command, args, { cwd, stdio: "inherit" }); - if (result.error) throw result.error; - assert.equal(result.status, 0, `${command} ${args.join(" ")} failed`); -} +const consumerDirectory = mkdtempSync(join(tmpdir(), "webmcp-consumer-")); +const packageDirectory = fileURLToPath(new URL(".", import.meta.url)); + try { - run("pnpm", ["pack", "--pack-destination", consumer], root); - const archive = readdirSync(consumer).find((name) => name.endsWith(".tgz")); + run("pnpm", ["pack", "--pack-destination", consumerDirectory], packageDirectory); + const archive = readdirSync(consumerDirectory).find((name) => name.endsWith(".tgz")); assert.ok(archive, "pnpm pack produced no tarball"); - writeFileSync(join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" })); - // Resolve declared dependencies with a fresh metadata cache, as a clean CI runner does. + writeFileSync( + join(consumerDirectory, "package.json"), + JSON.stringify({ private: true, type: "module" }), + ); + + // Resolve published dependencies without this checkout's cached registry metadata. run("pnpm", [ "add", "--cache-dir", - join(consumer, "cache"), + join(consumerDirectory, "cache"), "--ignore-scripts", - join(consumer, archive), + join(consumerDirectory, archive), ]); - const installed = join(consumer, "node_modules/webmcp-polyfill"); - const files = readdirSync(installed, { + + const installedPackage = join(consumerDirectory, "node_modules/webmcp-polyfill"); + const files = readdirSync(installedPackage, { recursive: true, withFileTypes: true, }) .filter((entry) => entry.isFile()) - .map((entry) => relative(installed, join(entry.parentPath, entry.name)).replaceAll("\\", "/")) + .map((entry) => { + const path = relative(installedPackage, join(entry.parentPath, entry.name)); + return path.replaceAll("\\", "/"); + }) .sort(); assert.deepEqual(files, [ "LICENSE", @@ -44,30 +48,38 @@ try { "package.json", ]); assert.match( - readFileSync(join(installed, "dist/polyfill.js"), "utf8"), + readFileSync(join(installedPackage, "dist/polyfill.js"), "utf8"), /SPDX-License-Identifier: MIT/, "dist/polyfill.js lost its licence banner: check esbuild's --legal-comments=inline", ); - // Compile an auto-only consumer: source files in this checkout cannot supply - // missing ambient declarations or mask broken package exports. + + // Compile outside this checkout so its source files cannot mask missing public types. writeFileSync( - join(consumer, "consumer.ts"), + join(consumerDirectory, "consumer.ts"), ` import 'webmcp-polyfill/auto'; + const context: WebMCP.ModelContext | undefined = document.modelContext; - void context?.registerTool({ name: 'typed', description: 'Typed', - inputSchema: { type: 'object', properties: { count: { type: 'number' } }, required: ['count'] }, + void context?.registerTool({ + name: 'typed', + description: 'Typed', + inputSchema: { + type: 'object', + properties: { count: { type: 'number' } }, + required: ['count'], + }, execute(input) { const count: number = input.count; - // @ts-expect-error inferred count must not become any + // @ts-expect-error The schema infers count as a number. const invalid: string = input.count; return { count }; }, }); + async function invoke(context: WebMCP.ModelContext) { const [tool] = await context.getTools(); const result: string = await context.executeTool(tool, { count: 1 }); - // @ts-expect-error legacy JSON-string input is not supported + // @ts-expect-error The current draft accepts objects, not serialized JSON. await context.executeTool(tool, '{}'); return result; } @@ -78,7 +90,7 @@ try { ["ESNext", "Bundler"], ]) { run(process.execPath, [ - join(root, "node_modules/typescript/bin/tsc"), + join(packageDirectory, "node_modules/typescript/bin/tsc"), "--strict", "--noEmit", "--target", @@ -90,14 +102,27 @@ try { "consumer.ts", ]); } + run(process.execPath, [ "--input-type=module", "-e", - "import { installWebMCP } from 'webmcp-polyfill'; import 'webmcp-polyfill/auto'; installWebMCP();", + ` + import { installWebMCP } from 'webmcp-polyfill'; + import 'webmcp-polyfill/auto'; + installWebMCP(); + `, ]); console.log( "Packed consumer: public types, inference, SSR imports, and package contents passed.", ); } finally { - rmSync(consumer, { recursive: true, force: true }); + rmSync(consumerDirectory, { recursive: true, force: true }); +} + +function run(command, args, cwd = consumerDirectory) { + const result = spawnSync(command, args, { cwd, stdio: "inherit" }); + if (result.error) { + throw result.error; + } + assert.equal(result.status, 0, `${command} ${args.join(" ")} failed`); } diff --git a/wpt-metadata/webmcp/declarative/duplicate-tool-name.https.html.ini b/wpt-metadata/webmcp/declarative/duplicate-tool-name.https.html.ini index 6796338..325383a 100644 --- a/wpt-metadata/webmcp/declarative/duplicate-tool-name.https.html.ini +++ b/wpt-metadata/webmcp/declarative/duplicate-tool-name.https.html.ini @@ -1,4 +1,4 @@ -# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +# Forms do not register tools, so the initial toolchange event never fires. [duplicate-tool-name.https.html] expected: TIMEOUT [Test that duplicate declarative tools with different descriptions do not crash and only the first is registered] diff --git a/wpt-metadata/webmcp/declarative/executeTool-abort.https.html.ini b/wpt-metadata/webmcp/declarative/executeTool-abort.https.html.ini index 58f3671..ba6fca0 100644 --- a/wpt-metadata/webmcp/declarative/executeTool-abort.https.html.ini +++ b/wpt-metadata/webmcp/declarative/executeTool-abort.https.html.ini @@ -1,4 +1,4 @@ -# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +# Declarative form registration is unsupported; waitForTool() never resolves. [executeTool-abort.https.html] expected: TIMEOUT [executeTool signal successfully resets tool pseudo-classes] diff --git a/wpt-metadata/webmcp/declarative/executeTool-respondWith-circular-object.https.html.ini b/wpt-metadata/webmcp/declarative/executeTool-respondWith-circular-object.https.html.ini index 800220a..3fbf01a 100644 --- a/wpt-metadata/webmcp/declarative/executeTool-respondWith-circular-object.https.html.ini +++ b/wpt-metadata/webmcp/declarative/executeTool-respondWith-circular-object.https.html.ini @@ -1,4 +1,4 @@ -# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +# Declarative form registration is unsupported; waitForTool() never resolves. [executeTool-respondWith-circular-object.https.html] expected: TIMEOUT [Declarative tool executeTool() rejects when respondWith() receives a circular object] diff --git a/wpt-metadata/webmcp/declarative/execute_tool_change_event.https.html.ini b/wpt-metadata/webmcp/declarative/execute_tool_change_event.https.html.ini index fa421e1..9c5a92d 100644 --- a/wpt-metadata/webmcp/declarative/execute_tool_change_event.https.html.ini +++ b/wpt-metadata/webmcp/declarative/execute_tool_change_event.https.html.ini @@ -1,4 +1,4 @@ -# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +# Declarative form registration is unsupported; waitForTool() never resolves. [execute_tool_change_event.https.html] expected: TIMEOUT [executeTool triggers input and change events] diff --git a/wpt-metadata/webmcp/declarative/execute_tool_submit_from_js.https.html.ini b/wpt-metadata/webmcp/declarative/execute_tool_submit_from_js.https.html.ini index 96caad0..6fb81b5 100644 --- a/wpt-metadata/webmcp/declarative/execute_tool_submit_from_js.https.html.ini +++ b/wpt-metadata/webmcp/declarative/execute_tool_submit_from_js.https.html.ini @@ -1,4 +1,4 @@ -# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +# Declarative form registration is unsupported; waitForTool() never resolves. [execute_tool_submit_from_js.https.html] expected: TIMEOUT [executeTool resolves successfully when submit handler calls form.submit()] diff --git a/wpt-metadata/webmcp/declarative/form_removal_submit_crash.https.html.ini b/wpt-metadata/webmcp/declarative/form_removal_submit_crash.https.html.ini index 27b3352..379ab35 100644 --- a/wpt-metadata/webmcp/declarative/form_removal_submit_crash.https.html.ini +++ b/wpt-metadata/webmcp/declarative/form_removal_submit_crash.https.html.ini @@ -1,4 +1,4 @@ -# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +# Declarative form registration is unsupported; waitForTool() never resolves. [form_removal_submit_crash.https.html] expected: TIMEOUT [Form removal on submit with respondWith does not crash the renderer] diff --git a/wpt-metadata/webmcp/declarative/getTools-declarative-schema.https.html.ini b/wpt-metadata/webmcp/declarative/getTools-declarative-schema.https.html.ini index f0e1ed0..628535a 100644 --- a/wpt-metadata/webmcp/declarative/getTools-declarative-schema.https.html.ini +++ b/wpt-metadata/webmcp/declarative/getTools-declarative-schema.https.html.ini @@ -1,4 +1,4 @@ -# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +# Forms do not register tools, so the schema helper never finds a matching tool. [getTools-declarative-schema.https.html] expected: TIMEOUT [WebMCP: getTools() retrieves declarative schema at registration time] diff --git a/wpt-metadata/webmcp/declarative/no-frame-documents.https.html.ini b/wpt-metadata/webmcp/declarative/no-frame-documents.https.html.ini index e681a29..1a21161 100644 --- a/wpt-metadata/webmcp/declarative/no-frame-documents.https.html.ini +++ b/wpt-metadata/webmcp/declarative/no-frame-documents.https.html.ini @@ -1,4 +1,4 @@ -# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +# The iframe form never registers a tool, so waitForTool() never resolves. [no-frame-documents.https.html] expected: TIMEOUT [Declarative tool in iframe removed after registration does not crash on attribute modification] diff --git a/wpt-metadata/webmcp/declarative/opaque-origin-tools.https.html.ini b/wpt-metadata/webmcp/declarative/opaque-origin-tools.https.html.ini index fce7c1f..dc1125c 100644 --- a/wpt-metadata/webmcp/declarative/opaque-origin-tools.https.html.ini +++ b/wpt-metadata/webmcp/declarative/opaque-origin-tools.https.html.ini @@ -1,4 +1,4 @@ -# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +# Declarative form registration is unsupported; waitForTool() never resolves. [opaque-origin-tools.https.html] expected: TIMEOUT [An opaque origin document can register but not execute its own declarative tools] diff --git a/wpt-metadata/webmcp/declarative/select-multiple-events.https.html.ini b/wpt-metadata/webmcp/declarative/select-multiple-events.https.html.ini index 1a12472..1b7bb91 100644 --- a/wpt-metadata/webmcp/declarative/select-multiple-events.https.html.ini +++ b/wpt-metadata/webmcp/declarative/select-multiple-events.https.html.ini @@ -1,4 +1,4 @@ -# Declarative form tools are not implemented; waits for form registration or toolchange cannot complete. +# Declarative form registration is unsupported; waitForTool() never resolves. [select-multiple-events.https.html] expected: TIMEOUT [executeTool() on a