diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a046e53 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,93 @@ +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 + - name: Read WPT revision + id: wpt + run: echo "revision=$(cat wpt/revision.txt)" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v7 + with: + repository: web-platform-tests/wpt + ref: ${{ steps.wpt.outputs.revision }} + 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..488144e --- /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..b0a89e6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# Working on the polyfill + +This package implements the document-local imperative WebMCP draft. Use the +official `webmcp-types` dependency; do not duplicate its declarations. +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 is in + `wpt/revision.txt`, selection in `wpt/run.ts`, and disagreements 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 `src/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 updating the WPT revision, read the upstream diff first, update the coverage +counts in `wpt/run.ts`, 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 + +Favor a clear reading order over minimum line count. Use braces, name intermediate +values by their role, and keep argument conversion separate from tool operations. +Put the public entry point and operations before their lower-level helpers. + +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..908d194 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# 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 in development. 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, 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: + +```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 }, +); + +const tools = await context.getTools(); +const pageTitleTool = tools.find((tool) => tool.name === "page-title"); +if (!pageTitleTool) { + throw new Error("The page-title tool is unavailable"); +} + +const result = await context.executeTool(pageTitleTool, {}); +console.log(result); + +// Remove the tool when it is no longer needed. +registration.abort(); +``` + +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 + +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. + +`executeTool()` accepts an object and returns a JSON-serialized result. Callbacks must validate their inputs; schema inference provides TypeScript checks only. + +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. + +Breaking API changes ship with notes: in minor releases while the version is 0.x, in majors after 1.0. + +## Development + +`src/` contains the polyfill and its automatic entry point. `tests/` contains the browser and package checks with their fixtures. `wpt/` contains the upstream test runner, pinned revision, and expectations. + +See [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) for browser setup and test commands. + +## License + +[MIT](LICENSE). diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..6e63aae --- /dev/null +++ b/TESTING.md @@ -0,0 +1,126 @@ +# Testing and upstream tracking + +## Browser and package checks + +Use Node.js 24 and pnpm: + +```sh +pnpm install --frozen-lockfile +pnpm exec playwright install --with-deps chromium firefox webkit +pnpm test +pnpm test:package +``` + +`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. +Browser tests in `tests/*.test.ts` are discovered automatically. +`tests/native.test.ts` runs only in the native Chromium project; +`tests/package.test.ts` runs separately through `pnpm test:package`. +All source, tests, and Node scripts are type-checked, including the browser +fixture JavaScript through `checkJs`. Node 24 runs the TypeScript scripts directly; +they use erasable syntax and need no separate compilation step. + +| File | Coverage | +| ----------------------- | ------------------------------------------------------------------------------------------- | +| `tests/index.test.ts` | Registration, discovery, conversion, metadata copies, events, abort, and detached documents | +| `tests/execute.test.ts` | Object input, JSON results, cancellation, concurrent calls, and dispatch failures | +| `tests/app.test.ts` | Button interactions, callback side effects, invalid input, unregistration, and reload | +| `tests/native.test.ts` | Preservation of the native context, getter, and tools | +| `tests/index.test-d.ts` | Published declarations and upstream schema inference | +| `tests/package.test.ts` | Packed consumer imports, type inference, SSR entry points, and package contents | + +The fixture server, `tests/fixtures/server.ts`, 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). +CI and the local runner both read the pin from `wpt/revision.txt`. +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. +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. + +`wpt/run.ts` 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). +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 +`wpt/revision.txt` and the runner's coverage counts, and record browser versions +and results separately. diff --git a/package.json b/package.json new file mode 100644 index 0000000..63978e4 --- /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 src/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/run.ts", + "prepack": "pnpm build", + "test:package": "node tests/package.test.ts", + "lint": "oxlint --deny-warnings" + }, + "dependencies": { + "webmcp-types": "^0.1.8" + }, + "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/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..11f3fb3 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + testMatch: "**/*.test.ts", + testIgnore: ["**/native.test.ts", "**/package.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 tests/fixtures/server.ts", + url: "http://localhost:8793/health", + reuseExistingServer: false, + }, + projects: [ + { + name: "chromium", + use: { + browserName: "chromium", + launchOptions: { args: ["--disable-features=WebMCP"] }, + }, + }, + { name: "firefox", use: { browserName: "firefox" } }, + { name: "webkit", use: { browserName: "webkit" } }, + { + name: "native-chromium", + testMatch: "native.test.ts", + testIgnore: [], + 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..f590c68 --- /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.8 + version: 0.1.8 + 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.8: + resolution: {integrity: sha512-YtI13B7j9v8F4j+UyVr5ohg1GHl3IMu/Ozw08RqGh58Ft29rrr4Wh5W/Y3WfkARjchrcTpoOPxa939reQZ1Zzg==} + +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.8: {} diff --git a/src/auto.ts b/src/auto.ts new file mode 100644 index 0000000..43e63ce --- /dev/null +++ b/src/auto.ts @@ -0,0 +1,4 @@ +import { installWebMCP } from "./index.js"; +export type { WebMCP } from "./index.js"; + +installWebMCP(); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f035dae --- /dev/null +++ b/src/index.ts @@ -0,0 +1,568 @@ +/*! + * Copyright (c) 2026 WebMCP polyfill contributors + * SPDX-License-Identifier: MIT + */ + +import type { WebMCP } from "webmcp-types"; +export type { WebMCP } from "webmcp-types"; + +// Detached windows may stop exposing these bindings. +const NativeDOMException = globalThis.DOMException; +const getWindow = Object.getOwnPropertyDescriptor(globalThis, "window")?.get; + +interface StoredTool { + metadata: Omit; + // Snapshot at registration; parse a fresh copy for each discovery result. + serializedSchema?: string; + execute: WebMCP.ToolExecuteCallback; +} + +const contexts = new WeakMap(); + +/** + * Install document-local WebMCP in the current window. + * + * Does nothing outside a secure browser context or when `document.modelContext` + * already exists, including partial native implementations. Call before registering + * tools in each frame; repeated calls preserve existing contexts and registrations. + * + * @throws {TypeError} If the window or Document prototype prevents installation. + * @example + * import { installWebMCP } from "webmcp-polyfill"; + * installWebMCP(); + * + * @see https://webmachinelearning.github.io/webmcp/#document-extension + * @see https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md + */ +export function installWebMCP(): void { + if (typeof document === "undefined" || !globalThis.isSecureContext) { + return; + } + if ("modelContext" in document) { + return; + } + + const documentPrototype = Document.prototype; + const getDefaultView = Object.getOwnPropertyDescriptor(documentPrototype, "defaultView")!.get!; + const constructorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "ModelContext"); + if ( + !Object.isExtensible(documentPrototype) || + (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, + }); + + // A method is non-constructible; defaultView supplies the native Document brand check. + const { getModelContext } = { + getModelContext(this: Document): WebMCP.ModelContext { + getDefaultView.call(this); + let context = contexts.get(this); + if (!context) { + context = new ModelContextPolyfill(this); + contexts.set(this, context); + } + return context; + }, + }; + Object.defineProperty(getModelContext, "name", { value: "get modelContext" }); + Object.defineProperty(documentPrototype, "modelContext", { + configurable: true, + enumerable: true, + get: getModelContext, + }); +} + +class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { + readonly #document: Document; + readonly #tools = new Map(); + #toolchangeHandler: WebMCP.ModelContext["ontoolchange"] = null; + readonly #toolchangeListener = (event: Event): void => { + const handler = this.#toolchangeHandler; + if (handler) { + const result = Reflect.apply(handler, this, [event]); + if (result === false) { + Event.prototype.preventDefault.call(event); + } + } + }; + + constructor(owner: Document) { + super(); + this.#document = owner; + } + + get ontoolchange(): WebMCP.ModelContext["ontoolchange"] { + return this.#toolchangeHandler; + } + + set ontoolchange(handler: WebMCP.ModelContext["ontoolchange"]) { + const nextHandler = typeof handler === "function" ? handler : null; + // Replacing a handler preserves its listener position; clearing it removes that position. + if (!this.#toolchangeHandler && nextHandler) { + this.addEventListener("toolchange", this.#toolchangeListener); + } + if (this.#toolchangeHandler && !nextHandler) { + this.removeEventListener("toolchange", this.#toolchangeListener); + } + this.#toolchangeHandler = nextHandler; + } + + // Default parameters preserve Web IDL's required-argument counts in function.length. + async registerTool( + tool: object, + options: WebMCP.ModelContextRegisterToolOptions = {}, + ): Promise { + const { name, title, description, annotations, inputSchema, execute } = + readToolDefinition(tool); + const settings = readDictionary(options); + const exposedTo = readOriginSequence(settings.exposedTo); + const registrationSignal = readAbortSignal(settings.signal); + + const ownerWindow = requireActiveWindow(this.#document); + + // 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 serializedSchema = inputSchema === undefined ? undefined : serializeJSON(inputSchema); + registrationSignal?.throwIfAborted(); + rejectUnsupportedOrigins(exposedTo); + + const storedTool: StoredTool = { + metadata: { + name, + title, + description, + annotations, + window: ownerWindow, + origin: ownerWindow.origin, + }, + serializedSchema, + execute, + }; + + return new Promise((resolve, reject) => { + // Abort unregisters the tool and also rejects any pending registration. + registrationSignal?.addEventListener( + "abort", + () => { + this.#tools.delete(name); + this.#queueToolChange(); + reject(registrationSignal.reason); + }, + { once: true }, + ); + + this.#tools.set(name, storedTool); + this.#queueToolChange(); + queueTask(resolve); + }); + } + + async getTools( + options: WebMCP.ModelContextGetToolOptions = {}, + ): Promise { + const settings = readDictionary(options); + const fromOrigins = readOriginSequence(settings.fromOrigins); + + requireActiveWindow(this.#document); + rejectUnsupportedOrigins(fromOrigins); + + const tools = Array.from(this.#tools.values(), copyToolMetadata); + + // Compare code units; localeCompare() would change the draft's sort order. + tools.sort((left, right) => { + if (left.name === right.name) { + return 0; + } + return left.name < right.name ? -1 : 1; + }); + + return new Promise((resolve) => { + queueTask(() => resolve(tools)); + }); + } + + async executeTool( + tool: WebMCP.RegisteredTool, + inputObject: object | undefined = undefined, + options: WebMCP.ModelContextExecuteToolOptions = {}, + ): Promise { + const target = readExecutionTarget(tool); + const settings = readDictionary(options); + const callerSignal = readAbortSignal(settings.signal); + + requireActiveWindow(this.#document); + const expectedOrigin = URL.parse(target.origin)?.origin; + if (!expectedOrigin || expectedOrigin === "null") { + throw new NativeDOMException("Invalid or opaque origin", "NotSupportedError"); + } + if (!isObject(inputObject)) { + throw new TypeError("inputObject must be an object"); + } + + const serializedInput = serializeJSON(inputObject); + callerSignal?.throwIfAborted(); + if (target.window !== this.#document.defaultView) { + // Cross-document routing is unsupported; dispatch failures use UnknownError. + throw new NativeDOMException("Tool execution failed", "UnknownError"); + } + + return new Promise((resolve, reject) => { + const callbackController = new AbortController(); + // Cancellation can arrive after the callback finishes but before its result is delivered; + // the promise is already rejected then, so a late resolve is ignored. + let callbackFinished = false; + + const onCallerAbort = (): void => { + reject(callerSignal!.reason); + + // Reject the caller first; the running callback receives a default AbortError. + queueTask(() => { + if (!callbackFinished) { + callbackController.abort(); + } + }); + }; + + const rejectExecution = (): void => { + callbackFinished = true; + queueTask(() => { + callerSignal?.removeEventListener("abort", onCallerAbort); + reject(new NativeDOMException("Tool execution failed", "UnknownError")); + }); + }; + + const completeExecution = (value: unknown): void => { + callbackFinished = true; + // A cancelled call must not run the author's toJSON during serialization. + if (callerSignal?.aborted) { + return; + } + + try { + const serializedResult = serializeJSON(value); + queueTask(() => { + callerSignal?.removeEventListener("abort", onCallerAbort); + resolve(serializedResult); + }); + } catch { + rejectExecution(); + } + }; + + const dispatchTool = (): void => { + if (callerSignal?.aborted) { + return; + } + + try { + const ownerWindow = requireActiveWindow(this.#document); + const storedTool = this.#tools.get(target.name); + if (!storedTool || expectedOrigin !== ownerWindow.origin) { + rejectExecution(); + return; + } + + const input: unknown = JSON.parse(serializedInput); + if (!isObject(input)) { + rejectExecution(); + return; + } + + // The callback runs without the registration object as its receiver. + const execute = storedTool.execute; + const callbackResult = execute(input, { signal: callbackController.signal }); + Promise.resolve(callbackResult).then(completeExecution, rejectExecution); + } catch { + rejectExecution(); + } + }; + + callerSignal?.addEventListener("abort", onCallerAbort, { once: true }); + queueTask(dispatchTool); + }); + } + + #queueToolChange(): void { + queueTask(() => this.dispatchEvent(new Event("toolchange"))); + } +} + +// Web IDL exposes a non-constructible interface with enumerable prototype members. +const modelContextConstructor = function ModelContext(): never { + throw new TypeError("Illegal constructor"); +}; +// Preserve the public name through minification. +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 }, + registerTool: { enumerable: true }, + getTools: { enumerable: true }, + executeTool: { enumerable: true }, + ontoolchange: { enumerable: true }, +}); + +// Web IDL reads and converts dictionary members in lexicographical order. +function readToolDefinition(value: unknown) { + const descriptor = readDictionary(value); + const annotations = readAnnotations(descriptor.annotations); + const description = toDOMString(requireMember(descriptor.description, "description")); + const callback = requireMember(descriptor.execute, "execute"); + if (typeof callback !== "function") { + throw new TypeError("execute must be a function"); + } + // SAFETY: callability is checked above; inputs and results are converted at invocation. + const execute = callback as WebMCP.ToolExecuteCallback; + const inputSchema = readInputSchema(descriptor.inputSchema); + const name = toDOMString(requireMember(descriptor.name, "name")); + const rawTitle = descriptor.title; + const title = rawTitle === undefined ? "" : toUSVString(rawTitle); + + return { name, title, description, annotations, inputSchema, execute }; +} + +// Convert the whole RegisteredTool dictionary, even members not used for dispatch. +function readExecutionTarget(value: unknown) { + const descriptor = readDictionary(value); + readAnnotations(descriptor.annotations); + toDOMString(requireMember(descriptor.description, "description")); + readInputSchema(descriptor.inputSchema); + const name = toDOMString(requireMember(descriptor.name, "name")); + const origin = toUSVString(requireMember(descriptor.origin, "origin")); + const title = descriptor.title; + if (title !== undefined) { + toDOMString(title); + } + const targetWindow = requireMember(descriptor.window, "window"); + if (!isObject(targetWindow)) { + throw new TypeError("window must be a Window"); + } + // The native getter checks the Window brand across realms without changing identity. + getWindow!.call(targetWindow); + + return { name, origin, window: targetWindow }; +} + +function readInputSchema(value: unknown): object | undefined { + if (value !== undefined && !isObject(value)) { + throw new TypeError("inputSchema must be an object"); + } + return value; +} + +function copyToolMetadata({ metadata, serializedSchema }: StoredTool): WebMCP.RegisteredTool { + // SAFETY: the draft keeps whatever JSON toJSON produced; the cast matches RegisteredTool's type. + const inputSchema = + serializedSchema === undefined ? undefined : (JSON.parse(serializedSchema) as object); + + // Insert members in Web IDL order, then omit absent optional members. + const tool: WebMCP.RegisteredTool = { + annotations: metadata.annotations ? { ...metadata.annotations } : undefined, + description: metadata.description, + inputSchema, + name: metadata.name, + origin: metadata.origin, + title: metadata.title, + window: metadata.window, + }; + + if (tool.annotations === undefined) { + delete tool.annotations; + } + if (tool.inputSchema === undefined) { + delete tool.inputSchema; + } + return tool; +} + +function isObject(value: unknown): value is object { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +// https://webidl.spec.whatwg.org/#es-dictionary +function readDictionary(value: unknown): Record { + if (value == null) { + return {}; + } + if (!isObject(value)) { + throw new TypeError("Expected a dictionary"); + } + // SAFETY: the object check permits property reads; each member still needs conversion. + return value as Record; +} + +function readAnnotations(value: unknown): WebMCP.ToolAnnotations | undefined { + if (value === undefined) { + return undefined; + } + const annotations = readDictionary(value); + return { + consequentialHint: Boolean(annotations.consequentialHint), + readOnlyHint: Boolean(annotations.readOnlyHint), + untrustedContentHint: Boolean(annotations.untrustedContentHint), + }; +} + +// https://webidl.spec.whatwg.org/#es-DOMString +function toDOMString(value: unknown): string { + if (typeof value === "symbol") { + throw new TypeError("Cannot convert a Symbol to a string"); + } + return String(value); +} + +// https://webidl.spec.whatwg.org/#es-USVString +function toUSVString(value: unknown): string { + return toDOMString(value).toWellFormed(); +} + +function requireMember(value: unknown, name: string): unknown { + if (value === undefined) { + throw new TypeError(`${name} is required`); + } + return value; +} + +function serializeJSON(value: unknown): string { + const result = JSON.stringify(value); + if (result === undefined) { + throw new TypeError("Value is not JSON-serializable"); + } + return result; +} + +function readAbortSignal(value: unknown): AbortSignal | undefined { + if (value === undefined) { + return undefined; + } + // SAFETY: any() validates the native brand across realms before we use the signal. + // Composition also survives stopImmediatePropagation() on the original signal. + return AbortSignal.any([value as AbortSignal]); +} + +// https://webidl.spec.whatwg.org/#es-sequence +function readOriginSequence(value: unknown): string[] { + if (value === undefined) { + return []; + } + if (!isObject(value)) { + throw new TypeError("Origins must be a sequence"); + } + const getIterator = readDictionary(value)[Symbol.iterator]; + if (typeof getIterator !== "function") { + throw new TypeError("Origins must be a sequence"); + } + // Use the cached method and original receiver without reading the method's own properties. + const iterable = { + [Symbol.iterator]() { + return Reflect.apply(getIterator, value, []); + }, + }; + return Array.from(iterable, toUSVString); +} + +// Validate before refusing cross-document support, preserving SecurityError precedence. +// Scheme and host checks cannot recognize browser-specific trusted origins. +function rejectUnsupportedOrigins(origins: string[]): void { + for (const origin of origins) { + let url = URL.parse(origin); + if (!url) { + throw new NativeDOMException("Invalid origin", "SecurityError"); + } + // blob: URLs inherit their origin's scheme and host. + if (url.origin !== "null") { + url = new URL(url.origin); + } + const isLoopback = + url.hostname === "[::1]" || + // URL canonicalizes numeric hosts; exclude domains such as 127.example.test. + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(url.hostname); + const isLocalhost = + url.hostname === "localhost" || + url.hostname === "localhost." || + url.hostname.endsWith(".localhost") || + url.hostname.endsWith(".localhost."); + const isSecureScheme = ["https:", "wss:", "file:"].includes(url.protocol); + const isLocalHttp = ["http:", "ws:"].includes(url.protocol) && (isLoopback || isLocalhost); + + if (!isSecureScheme && !isLocalHttp) { + throw new NativeDOMException("Origin is not potentially trustworthy", "SecurityError"); + } + } + if (origins.length) { + throw new NativeDOMException("Cross-document tools require native WebMCP", "NotSupportedError"); + } +} + +function requireActiveWindow(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"); + } + + requireToolsPermission(owner, view); + return view; +} + +function requireToolsPermission(owner: Document, view: Window): void { + // 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); + 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")) { + throw new NativeDOMException("WebMCP is disabled by Permissions Policy", "NotAllowedError"); + } + return; + } + // Same-origin access approximates the default policy; explicit allowlists need native support. + try { + void view.parent.document; + } catch { + throw new NativeDOMException( + "Cross-origin frames require native Permissions Policy", + "NotAllowedError", + ); + } +} + +// Timers approximate the WebMCP task source; navigation cleanup requires native support. +function queueTask(callback: () => void): void { + setTimeout(callback, 0); +} diff --git a/tests/app.test.ts b/tests/app.test.ts new file mode 100644 index 0000000..d798f88 --- /dev/null +++ b/tests/app.test.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; + +test("a served application executes, rejects invalid input, 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"); + + 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"); + const tools = await page.evaluate(() => document.modelContext!.getTools()); + expect(tools).toEqual([]); + + await page.reload(); + await expect(page.locator("#status")).toHaveText("registered"); + await expect(page.locator("#count")).toHaveText("0"); + expect(errors).toEqual([]); +}); diff --git a/tests/execute.test.ts b/tests/execute.test.ts new file mode 100644 index 0000000..a1cb398 --- /dev/null +++ b/tests/execute.test.ts @@ -0,0 +1,597 @@ +import { test, expect } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + const hasNativeContext = await page.evaluate(() => "modelContext" in document); + expect(hasNativeContext).toBe(false); + await page.addScriptTag({ url: "/auto.js" }); +}); + +test("ignores late results after cancellation, including serialization side effects", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + 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 completion; + }, + }); + + 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; + }); + + expect(outcome).toBe(false); +}); + +test("cancellation during result serialization wins over queued delivery", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + const caller = new AbortController(); + let callbackSignal: AbortSignal | undefined; + + await context.registerTool({ + name: "serialization", + description: "Serialization", + execute(_input, { signal }) { + callbackSignal = signal; + return { + toJSON() { + caller.abort("cancelled during serialization"); + return "completed"; + }, + }; + }, + }); + + const [tool] = await context.getTools(); + const reason = await context + .executeTool(tool, {}, { signal: caller.signal }) + .catch((error) => error); + await context.getTools(); + return { reason, callbackAborted: callbackSignal?.aborted }; + }); + + expect(outcome).toEqual({ + reason: "cancelled during serialization", + callbackAborted: false, + }); +}); + +test("concurrent calls to the same tool have independent cancellation", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + const signals: AbortSignal[] = []; + const { promise: bothStarted, resolve: start } = Promise.withResolvers(); + const { promise: completion, resolve: finish } = Promise.withResolvers(); + + await context.registerTool({ + name: "concurrent", + description: "Concurrent", + execute(_input, { signal }) { + signals.push(signal); + if (signals.length === 2) { + start(); + } + return completion; + }, + }); + + 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), + }; + }); + + expect(outcome).toEqual({ + first: "first only", + second: '{"second":true}', + aborted: [true, false], + }); +}); + +test("executes copied object and array inputs with a fresh callback signal", async ({ page }) => { + const outcome = 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; + const objectResult = JSON.parse(await pending); + const arrayResult = JSON.parse(await context.executeTool(tool, [1, 2])); + return [objectResult, arrayResult]; + }); + + expect(outcome).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 }) => { + const outcome = 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; + }); + + expect(outcome).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)); + 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, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + const { promise: started, resolve: entered } = Promise.withResolvers(); + const { promise: callbackAborted, resolve: observed } = Promise.withResolvers(); + + 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]; + }); + + expect(outcome).toEqual(["caller reason", "AbortError"]); +}); + +test("unregistration leaves an already-running invocation alive", async ({ page }) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + const registration = new AbortController(); + const { promise: started, resolve: entered } = Promise.withResolvers(); + const { promise: completion, resolve: complete } = Promise.withResolvers(); + + await context.registerTool( + { + name: "pending", + description: "Pending", + execute(_input, { signal }) { + entered(signal); + return completion; + }, + }, + { signal: registration.signal }, + ); + + const [tool] = await context.getTools(); + const pending = context.executeTool(tool, {}); + const callbackSignal = await started; + registration.abort(); + const count = (await context.getTools()).length; + complete("finished"); + return { count, aborted: callbackSignal.aborted, result: await pending }; + }); + + expect(outcome).toEqual({ count: 0, aborted: false, result: '"finished"' }); +}); + +// The polyfill dispatches on a timer; native task ordering can differ (see TESTING.md). +test("aborting before the dispatch task rejects without starting the callback", async ({ + page, +}) => { + const outcome = 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]; + }); + + expect(outcome).toEqual([0, "before dispatch"]); +}); + +test("execution converts every descriptor member before invoking the tool", async ({ page }) => { + 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 invalidDescriptors = [ + { ...tool, title: Symbol() }, + { ...tool, inputSchema: 1 }, + { ...tool, annotations: 1 }, + { ...tool, window: fakeWindow }, + { ...tool, window: null }, + ]; + for (const descriptor of invalidDescriptors) { + 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 }) => { + const outcome = 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; + } + }); + + expect(outcome).toBe("UnknownError"); +}); + +test("descriptor origins are parsed, and a mismatch fails like a missing tool", async ({ + page, +}) => { + const outcome = 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; + }); + + expect(outcome).toEqual([ + ["unparseable", "NotSupportedError"], + ["opaque", "NotSupportedError"], + ["another origin", "UnknownError"], + ]); +}); + +test("a signal option that is not an AbortSignal is rejected", async ({ page }) => { + const outcome = 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"]) { + 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; + }); + + expect(outcome).toEqual([ + ["1", "TypeError"], + ["{}", "TypeError"], + ["null", "TypeError"], + ['"abort"', "TypeError"], + ]); +}); + +test("unregistering before dispatch rejects without running the callback", async ({ page }) => { + const outcome = 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]; + }); + + expect(outcome).toEqual(["UnknownError", 0]); +}); + +test("input that serializes to a non-object rejects before the callback runs", async ({ page }) => { + const outcome = 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]; + }); + + expect(outcome).toEqual(["UnknownError", 0]); +}); diff --git a/tests/fixtures/app.js b/tests/fixtures/app.js new file mode 100644 index 0000000..6d1cc6a --- /dev/null +++ b/tests/fixtures/app.js @@ -0,0 +1,60 @@ +const context = document.modelContext; +const countOutput = document.getElementById("count"); +const registrationStatus = document.getElementById("status"); +const executionResult = document.getElementById("result"); +const amountInput = document.getElementById("amount"); +if ( + !context || + !countOutput || + !registrationStatus || + !executionResult || + !(amountInput instanceof HTMLInputElement) +) { + throw new Error("The counter fixture requires WebMCP and its form elements"); +} + +/** @type {AbortController} */ +let registration; +let count = 0; + +const register = async () => { + 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; + countOutput.textContent = String(count); + return { count }; + }, + }, + { signal: registration.signal }, + ); + registrationStatus.textContent = "registered"; +}; + +document.getElementById("register")?.addEventListener("click", register); +document.getElementById("unregister")?.addEventListener("click", () => { + registration.abort(); + registrationStatus.textContent = "unregistered"; +}); +document.getElementById("execute")?.addEventListener("click", async () => { + try { + const [tool] = await context.getTools(); + const amount = Number(amountInput.value); + executionResult.textContent = await context.executeTool(tool, { amount }); + } catch (error) { + executionResult.textContent = error instanceof Error ? error.name : String(error); + } +}); + +await register(); diff --git a/tests/fixtures/server.ts b/tests/fixtures/server.ts new file mode 100644 index 0000000..7179746 --- /dev/null +++ b/tests/fixtures/server.ts @@ -0,0 +1,42 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; + +const appHtml = ` + +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") { + 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(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(appHtml); + } else { + response.writeHead(404).end(); + } + } catch (error) { + console.error(error); + response.writeHead(500).end(); + } +}).listen(8793, "127.0.0.1"); diff --git a/tests/index.test-d.ts b/tests/index.test-d.ts new file mode 100644 index 0000000..80510ab --- /dev/null +++ b/tests/index.test-d.ts @@ -0,0 +1,32 @@ +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 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, "{}"); +} diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..6927eaa --- /dev/null +++ b/tests/index.test.ts @@ -0,0 +1,731 @@ +import { test, expect } from "@playwright/test"; + +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" }); + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + const errors = []; + 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(); + errors.push("resolved"); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + errors.push(error.name); + } + } + return errors; + }); + + expect(outcome).toEqual(["SecurityError", "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. + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + 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", + "http://[::1]:8793", + "http://localhost:8793", + "http://localhost.:8793", + "http://app.localhost:8793", + "ws://localhost:8793", + "http://example.test", + "blob:http://example.test/id", + "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; + }); + + expect(outcome).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"], + ["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"], + ["blob:http://example.test/id", "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" }); + const outcome = await frame!.evaluate(async () => { + try { + await document.modelContext!.getTools(); + return "resolved"; + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + return error.name; + } + }); + + expect(outcome).toBe("NotAllowedError"); +}); + +test("a signal option that is not an AbortSignal is rejected", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const outcome = 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"]) { + 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; + }); + + expect(outcome).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" }); + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + let changes = 0; + context.ontoolchange = () => changes++; + let reason: unknown; + 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. + const tools = await context.getTools(); + return { reason, count: tools.length, changes }; + }); + + expect(outcome).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" }); + const outcome = 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; + }); + + expect(outcome).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" }); + const outcome = await page.evaluate(async () => { + 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([ + { 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 }) => { + // 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 tool = (await context.getTools())[0]; + const FrameException = iframe.contentDocument!.defaultView!.DOMException; + iframe.remove(); + const errors = []; + for (const operation of [ + () => context.getTools(), + () => + context.registerTool({ name: "detached", description: "Detached", execute: () => null }), + () => context.executeTool(tool, {}), + ]) { + try { + await operation(); + errors.push("resolved"); + } catch (error) { + errors.push(error instanceof FrameException ? error.name : "wrong realm"); + } + } + return errors; + }); + expect(frame).toEqual(["InvalidStateError", "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, context.executeTool.length], + constructionError, + getterErrors, + }; + }); + expect(result).toEqual({ + same: true, + members: ["executeTool", "getTools", "ontoolchange", "registerTool"], + own: [], + brand: "[object ModelContext]", + instance: true, + constructorParent: true, + constructorName: "ModelContext", + writable: false, + alias: false, + testing: false, + lengths: [1, 0, 1], + 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" }); + const outcome = await page.evaluate( + ([previous, previousGetter]) => ({ + context: document.modelContext === previous, + getter: + Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!.get === previousGetter, + }), + [initial, getter] as const, + ); + + expect(outcome).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), + keys: tools.map((tool) => Object.keys(tool)), + 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.keys).toEqual([ + ["description", "name", "origin", "title", "window"], + ["annotations", "description", "inputSchema", "name", "origin", "title", "window"], + ]); + 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 = [ + ["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 } }, + ], + ] as const; + 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]); + } + } + const tools = await context.getTools(); + return { rejections: results, registered: tools.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"], + ]); + expect(registered).toEqual(["valid"]); +}); + +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"; + const context = document.modelContext!; + const descriptor = { + name: 123, + title: "\ud800", + description: true, + annotations: { readOnlyHint: 1 }, + execute() { + return this === undefined; + }, + }; + // @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, + result: await context.executeTool(tool, {}), + }; + }); + expect(result).toEqual({ + name: "123", + title: "\ufffd", + description: "true", + annotations: { consequentialHint: false, readOnlyHint: true, untrustedContentHint: false }, + result: "true", + }); +}); + +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("ontoolchange ignores a handler's own call property", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const events = await page.evaluate(async () => { + const context = document.modelContext!; + const notifications: string[] = []; + context.ontoolchange = function (event) { + notifications.push(this === context ? event.type : "wrong receiver"); + }; + Object.defineProperty(context.ontoolchange, "call", { value: null }); + + await context.registerTool({ name: "event", description: "Event", execute: () => null }); + return notifications; + }); + + expect(events).toEqual(["toolchange"]); +}); + +test("returning false from ontoolchange cancels a cancelable event", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const outcome = await page.evaluate(() => { + const context = document.modelContext!; + context.ontoolchange = () => false; + const event = new Event("toolchange", { cancelable: true }); + const dispatched = context.dispatchEvent(event); + return { dispatched, defaultPrevented: event.defaultPrevented }; + }); + + expect(outcome).toEqual({ dispatched: false, defaultPrevented: true }); +}); + +test("preserves event-handler listener order when the handler is replaced", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const outcome = 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; + }); + + expect(outcome).toEqual(["first", "new", "last", "first", "last", "reassigned"]); +}); + +test("rejects aborted registration and permits reusing its name", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const outcome = 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); + const tools = await context.getTools(); + return [reason, tools.length]; + }); + + expect(outcome).toEqual(["cancel-registration", 1]); +}); + +test("validates origins and refuses cross-document exposure", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const outcome = 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; + }); + + expect(outcome).toEqual([ + "SecurityError", + "SecurityError", + "NotSupportedError", + "NotSupportedError", + ]); +}); + +test("inactive documents get their own context but cannot register tools", async ({ page }) => { + await page.addScriptTag({ url: "/auto.js" }); + const outcome = 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, + }; + }); + + expect(outcome).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/tests/native.test.ts b/tests/native.test.ts new file mode 100644 index 0000000..b7f69c2 --- /dev/null +++ b/tests/native.test.ts @@ -0,0 +1,39 @@ +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("/"); + const registerToolType = await page.evaluate(() => typeof document.modelContext?.registerTool); + expect(registerToolType).toBe("function"); + + const original = await page.evaluateHandle(async () => { + const context = document.modelContext!; + const getter = Object.getOwnPropertyDescriptor(Document.prototype, "modelContext")!.get; + await context.registerTool({ + name: "native", + description: "Native tool", + execute: () => ({ native: true }), + }); + return { context, getter }; + }); + + await page.addScriptTag({ url: "/auto.js" }); + + 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/tests/package.test.ts b/tests/package.test.ts new file mode 100644 index 0000000..2c521ed --- /dev/null +++ b/tests/package.test.ts @@ -0,0 +1,128 @@ +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 consumerDirectory = mkdtempSync(join(tmpdir(), "webmcp-consumer-")); +const packageDirectory = fileURLToPath(new URL("..", import.meta.url)); + +try { + 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(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(consumerDirectory, "cache"), + "--ignore-scripts", + join(consumerDirectory, archive), + ]); + + const installedPackage = join(consumerDirectory, "node_modules/webmcp-polyfill"); + const files = readdirSync(installedPackage, { + recursive: true, + withFileTypes: true, + }) + .filter((entry) => entry.isFile()) + .map((entry) => { + const path = relative(installedPackage, join(entry.parentPath, entry.name)); + return path.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(installedPackage, "dist/polyfill.js"), "utf8"), + /SPDX-License-Identifier: MIT/, + "dist/polyfill.js lost its licence banner: check esbuild's --legal-comments=inline", + ); + + // Compile outside this checkout so its source files cannot mask missing public types. + writeFileSync( + 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'], + }, + execute(input) { + const count: number = input.count; + // @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 The current draft accepts objects, not serialized JSON. + await context.executeTool(tool, '{}'); + return result; + } + `, + ); + for (const [module, moduleResolution] of [ + ["NodeNext", "NodeNext"], + ["ESNext", "Bundler"], + ]) { + run(process.execPath, [ + join(packageDirectory, "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(consumerDirectory, { recursive: true, force: true }); +} + +function run(command: string, args: string[], cwd = consumerDirectory): void { + 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/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..257fa6f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "lib": ["ESNext", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "rootDir": "src", + "outDir": "dist", + "types": [], + "verbatimModuleSyntax": true + }, + "files": ["src/index.ts", "src/auto.ts"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..9f93a47 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node", "webmcp-types"], + "allowJs": true, + "checkJs": true, + "erasableSyntaxOnly": true, + "noUncheckedSideEffectImports": true + }, + "include": ["tests/**/*.ts", "tests/**/*.js", "wpt/*.ts", "playwright.config.ts"] +} 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..325383a --- /dev/null +++ b/wpt/metadata/webmcp/declarative/duplicate-tool-name.https.html.ini @@ -0,0 +1,7 @@ +# 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] + 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..ba6fca0 --- /dev/null +++ b/wpt/metadata/webmcp/declarative/executeTool-abort.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form registration is unsupported; waitForTool() never resolves. +[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..3fbf01a --- /dev/null +++ b/wpt/metadata/webmcp/declarative/executeTool-respondWith-circular-object.https.html.ini @@ -0,0 +1,5 @@ +# 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] + 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..9c5a92d --- /dev/null +++ b/wpt/metadata/webmcp/declarative/execute_tool_change_event.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form registration is unsupported; waitForTool() never resolves. +[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..6fb81b5 --- /dev/null +++ b/wpt/metadata/webmcp/declarative/execute_tool_submit_from_js.https.html.ini @@ -0,0 +1,5 @@ +# 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()] + 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..379ab35 --- /dev/null +++ b/wpt/metadata/webmcp/declarative/form_removal_submit_crash.https.html.ini @@ -0,0 +1,5 @@ +# 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] + 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..628535a --- /dev/null +++ b/wpt/metadata/webmcp/declarative/getTools-declarative-schema.https.html.ini @@ -0,0 +1,5 @@ +# 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] + 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..1a21161 --- /dev/null +++ b/wpt/metadata/webmcp/declarative/no-frame-documents.https.html.ini @@ -0,0 +1,5 @@ +# 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] + 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..dc1125c --- /dev/null +++ b/wpt/metadata/webmcp/declarative/opaque-origin-tools.https.html.ini @@ -0,0 +1,7 @@ +# 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] + 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..1b7bb91 --- /dev/null +++ b/wpt/metadata/webmcp/declarative/select-multiple-events.https.html.ini @@ -0,0 +1,5 @@ +# Declarative form registration is unsupported; waitForTool() never resolves. +[select-multiple-events.https.html] + expected: TIMEOUT + [executeTool() on a