mcp-accessibility-scanner 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +265 -12
  2. package/index.d.ts +1 -1
  3. package/lib/browserContextFactory.js +1161 -94
  4. package/lib/browserContextFactory.js.map +1 -1
  5. package/lib/browserServerBackend.js +199 -22
  6. package/lib/browserServerBackend.js.map +1 -1
  7. package/lib/browserSessions.js +172 -0
  8. package/lib/browserSessions.js.map +1 -0
  9. package/lib/config.js +48 -7
  10. package/lib/config.js.map +1 -1
  11. package/lib/context.js +321 -71
  12. package/lib/context.js.map +1 -1
  13. package/lib/extension/cdpRelay.js +5 -0
  14. package/lib/extension/cdpRelay.js.map +1 -1
  15. package/lib/extension/extensionContextFactory.js +12 -1
  16. package/lib/extension/extensionContextFactory.js.map +1 -1
  17. package/lib/index.js +5 -1
  18. package/lib/index.js.map +1 -1
  19. package/lib/mcp/http.js +194 -44
  20. package/lib/mcp/http.js.map +1 -1
  21. package/lib/mcp/inProcessTransport.js.map +1 -1
  22. package/lib/mcp/mdb.js +7 -9
  23. package/lib/mcp/mdb.js.map +1 -1
  24. package/lib/mcp/proxyBackend.js +76 -18
  25. package/lib/mcp/proxyBackend.js.map +1 -1
  26. package/lib/mcp/server.js +70 -41
  27. package/lib/mcp/server.js.map +1 -1
  28. package/lib/mcp/sharedClientSlot.js +134 -0
  29. package/lib/mcp/sharedClientSlot.js.map +1 -0
  30. package/lib/mcp/tool.js +3 -0
  31. package/lib/mcp/tool.js.map +1 -1
  32. package/lib/networkPolicy.js +73 -0
  33. package/lib/networkPolicy.js.map +1 -0
  34. package/lib/program.js +91 -12
  35. package/lib/program.js.map +1 -1
  36. package/lib/response.js +16 -3
  37. package/lib/response.js.map +1 -1
  38. package/lib/sessionLog.js +36 -6
  39. package/lib/sessionLog.js.map +1 -1
  40. package/lib/tab.js +149 -18
  41. package/lib/tab.js.map +1 -1
  42. package/lib/tools/auditKeyboard.js +204 -4
  43. package/lib/tools/auditKeyboard.js.map +1 -1
  44. package/lib/tools/auditScreenReader.js +823 -0
  45. package/lib/tools/auditScreenReader.js.map +1 -0
  46. package/lib/tools/auditSite.js +268 -90
  47. package/lib/tools/auditSite.js.map +1 -1
  48. package/lib/tools/axe.js +479 -21
  49. package/lib/tools/axe.js.map +1 -1
  50. package/lib/tools/dialogs.js +18 -4
  51. package/lib/tools/dialogs.js.map +1 -1
  52. package/lib/tools/evaluate.js +34 -5
  53. package/lib/tools/evaluate.js.map +1 -1
  54. package/lib/tools/network.js +136 -8
  55. package/lib/tools/network.js.map +1 -1
  56. package/lib/tools/pdf.js +5 -2
  57. package/lib/tools/pdf.js.map +1 -1
  58. package/lib/tools/scanPageMatrix.js +135 -47
  59. package/lib/tools/scanPageMatrix.js.map +1 -1
  60. package/lib/tools/screenshot.js +8 -4
  61. package/lib/tools/screenshot.js.map +1 -1
  62. package/lib/tools/session.js +53 -0
  63. package/lib/tools/session.js.map +1 -0
  64. package/lib/tools/snapshot.js +266 -8
  65. package/lib/tools/snapshot.js.map +1 -1
  66. package/lib/tools/tool.js.map +1 -1
  67. package/lib/tools/utils.js +62 -6
  68. package/lib/tools/utils.js.map +1 -1
  69. package/lib/tools.js +11 -2
  70. package/lib/tools.js.map +1 -1
  71. package/lib/utils/dataUrl.js +58 -35
  72. package/lib/utils/dataUrl.js.map +1 -1
  73. package/lib/utils/fileUtils.js +35 -0
  74. package/lib/utils/fileUtils.js.map +1 -1
  75. package/lib/utils/guid.js +8 -0
  76. package/lib/utils/guid.js.map +1 -1
  77. package/lib/utils/jsSource.js +187 -0
  78. package/lib/utils/jsSource.js.map +1 -0
  79. package/lib/vscode/browserContextFactory.js +87 -0
  80. package/lib/vscode/browserContextFactory.js.map +1 -0
  81. package/lib/vscode/host.js +189 -33
  82. package/lib/vscode/host.js.map +1 -1
  83. package/lib/vscode/main.js +3 -35
  84. package/lib/vscode/main.js.map +1 -1
  85. package/package.json +13 -9
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
 
2
2
  # MCP Accessibility Scanner 🔍
3
3
 
4
+ [![MCP Toplist](https://mcptoplist.com/badge/io.github.JustasMonkev%2Fmcp-accessibility-scanner.svg)](https://mcptoplist.com/server/io.github.JustasMonkev%2Fmcp-accessibility-scanner)
5
+
4
6
  ## Star History
5
7
  [![Star History Chart](https://api.star-history.com/svg?repos=justasmonkev%2Fmcp-accessibility-scanner&type=Date)](https://api.star-history.com/svg?repos=justasmonkev%2Fmcp-accessibility-scanner&type=Date)
6
8
 
@@ -120,6 +122,7 @@ Interactive mode. Type "<tool-name> <json>" to call a tool. Ctrl+D to exit.
120
122
  > browser_navigate {"url": "https://example.com"}
121
123
  > scan_page {"violationsTag": ["wcag21aa"]}
122
124
  > audit_keyboard {"maxTabs": 30}
125
+ > audit_screen_reader {}
123
126
  ```
124
127
 
125
128
  Each line is `<tool-name> <json-arguments>`. Omit the JSON to pass `{}`.
@@ -217,19 +220,96 @@ Create a `config.json` file with the following options:
217
220
  - `browser.cdpTimeout`: Maximum time in milliseconds to wait when connecting to the CDP endpoint (default: `30000`)
218
221
  - `browser.cdpLaunch`: Launch a Chromium-family desktop app with CDP enabled, wait for the endpoint, and manage the child process lifecycle
219
222
  - CDP attach modes preserve the target browser's existing default-context settings instead of applying Playwright's defaults.
223
+ - `browser.contextOptions.storageState`: Start each session from a recorded Playwright storage state; applied in every mode except `--extension` (fresh contexts receive it at creation, reused contexts via `setStorageState()`). Sessions that share one reused context (non-isolated CDP modes) get the state applied once per context — a session joining a live context inherits its current state, not a fresh copy of the file; see [Auditing pages behind a login](#auditing-pages-behind-a-login)
220
224
  - `timeouts.navigationTimeout`: Maximum time for page navigation in milliseconds (default: `60000`)
221
225
  - `timeouts.defaultTimeout`: Default timeout for Playwright operations in milliseconds (default: `5000`)
222
- - `timeouts.settle`: How long to wait after each action for triggered work to settle before responding (default: `500`)
226
+ - `timeouts.settle`: How long to wait after every action before responding (default: `500`). An action that finishes quietly is first watched for up to 100ms (or the settle delay, whichever is shorter) so scheduled network work can still be awaited before the settle delay.
223
227
  - `network.allowedOrigins`: List of origins to allow (blocks all others if specified)
224
228
  - `network.blockedOrigins`: List of origins to block
229
+ - `outputDir`: Directory for output files — reports, screenshots, traces, and session logs (CLI: `--output-dir`, env: `PLAYWRIGHT_MCP_OUTPUT_DIR`). Defaults to a fresh directory under the system temp folder, resolved once per server run so all of a run's artifacts land together. The output location is always server configuration; the deprecated MCP roots capability (client workspace folders) is no longer consulted.
225
230
 
226
231
  CLI equivalents are also available: `--cdp-launch-command`, `--cdp-launch-args`, `--cdp-launch-cwd`, `--cdp-launch-port`, `--cdp-launch-startup-timeout`, `--cdp-endpoint`, `--cdp-header` (repeat for multiple headers, e.g. `--cdp-header "Authorization: Bearer <token>"`), and `--cdp-timeout`. The CDP headers and timeout can also be set via the `PLAYWRIGHT_MCP_CDP_HEADERS` (one `Name: Value` entry per line) and `PLAYWRIGHT_MCP_CDP_TIMEOUT` environment variables.
227
232
 
228
- Use `--timeout-settle` or `PLAYWRIGHT_MCP_TIMEOUT_SETTLE` to override the post-action settle delay.
233
+ Use `--timeout-settle` or `PLAYWRIGHT_MCP_TIMEOUT_SETTLE` to override the post-action settle delay. It applies after every action so delayed DOM-only updates are included in the response; a short observation window also catches scheduled requests and waits for them before that delay.
229
234
 
230
235
  #### HTTP Heartbeat
231
236
 
232
- When the server runs with `--port`, it sends MCP heartbeat pings for Streamable HTTP sessions. Set `PLAYWRIGHT_MCP_PING_TIMEOUT_MS` to override the default `5000` ms timeout. Set it to `0` or any negative value to disable heartbeat pings for clients or proxies that do not answer server-initiated pings.
237
+ When the server runs with `--port`, it sends MCP heartbeat pings for Streamable HTTP sessions. Set `PLAYWRIGHT_MCP_PING_TIMEOUT_MS` to override the default `5000` ms timeout. Set it to `0` or any negative value to disable heartbeat pings for clients or proxies that do not answer server-initiated pings. A client that answers `ping` with a JSON-RPC "method not found" error (as clients on the MCP 2026-07-28 revision do) is treated as alive: the server stops heartbeating that session instead of closing it. Only an unanswered ping (timeout) or a transport failure closes the session.
238
+
239
+ #### Clients without the initialize handshake
240
+
241
+ Clients on the MCP 2026-07-28 revision no longer send the `initialize` handshake. With `--port`, requests carrying the revision's per-request `_meta` envelope are served natively on the 2026-07-28 protocol: `server/discover` is answered (so clients negotiating with `versionNegotiation: 'auto'` or a `2026-07-28` pin connect directly), results carry `resultType` and the SEP-2549 cache fields — the tool list is advertised as cacheable for one hour with `cacheScope: "private"` — and the SEP-2243 standard headers (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`) are validated against the request body. Older handshake-free clients (2025-era requests without the envelope) are served statelessly as before. In both cases requests receive no heartbeat pings, and in the modes where the server creates browser contexts itself each request runs against a fresh default browser session: with the default persistent profile the per-request default context runs in its own disposable profile (like an explicit browser session), so parallel handshake-free requests do not contend for the stable profile — and the stable profile's sign-in state is not visible to them — while `--isolated`, remote endpoints and isolated CDP modes mint a fresh context per request anyway. Modes that reuse one live browser context are the exception: `--extension` (and a `browser_connect` or VS Code session switched to a connected-browser provider) and CDP attach without `--isolated` serve every handshake-free request from the same shared context, so its tabs, cookies and storage persist across requests — the same sharing that makes these modes refuse `browser_session_open` (in `--vscode` serving the session tools are the exception: they are host-scoped and keep running against the default provider even while switched — see [Browser Session Tools](#browser-session-tools)). With a pinned `--cdp-launch-port`, only one launched application can be served at a time, so a second handshake-free request arriving while another request's browser context is still live is rejected with a clear error instead of silently attaching to the first request's application. With `--user-data-dir`, each handshake-free request launches a browser in the one configured profile: the profile's state persists across requests, and parallel requests contend for its browser lock and can fail with "Browser is already in use". Elsewhere, browser state that must persist across handshake-free requests belongs in an explicit browser session — a `browserSessionId` handle minted by `browser_session_open` in one request resolves in later ones (see [Browser Session Tools](#browser-session-tools)). Clients that do send `initialize` keep the classic `Mcp-Session-Id` session behavior unchanged. When several such stateful clients are connected at once in the default persistent-profile mode, the first client's default context holds the stable profile — concurrent clients' default contexts run in their own disposable profiles (without the stable profile's sign-in state) until it is freed, instead of failing with "Browser is already in use".
242
+
243
+ ## Auditing pages behind a login
244
+
245
+ Most real audits target pages that only exist for a signed-in user. There are two ways to get there.
246
+
247
+ ### Interactive route (no setup)
248
+
249
+ Every tool shares one browser context, and `audit_site` crawls in a temporary tab of that same context, so cookies and local storage created while you drive the browser are already available to the crawl:
250
+
251
+ ```text
252
+ 1. browser_navigate to the login page
253
+ 2. browser_fill_form / browser_click to sign in
254
+ 3. browser_navigate to the first page you want audited
255
+ 4. audit_site — the crawl inherits the session you just created
256
+ ```
257
+
258
+ This works out of the box in every mode, including the default persistent-profile mode. With the default profile the session also survives across server restarts, so you usually only sign in once. The default profile is keyed to the server's working directory, so each workspace's server keeps its own sign-in state — servers launched for different workspaces neither share cookies nor contend for the same profile.
259
+
260
+ ### Storage state route (repeatable, CI-friendly)
261
+
262
+ Record a session once with Playwright's codegen, then hand the file to the server:
263
+
264
+ ```bash
265
+ npx playwright@1.62.1 codegen --save-storage=auth.json https://example.com/login
266
+ ```
267
+
268
+ Sign in in the opened browser, then close it — `auth.json` now holds the cookies and local storage.
269
+
270
+ Pass it to the server with the CLI flag, the environment variable, or the config file:
271
+
272
+ ```bash
273
+ npx mcp-accessibility-scanner --isolated --storage-state ./auth.json
274
+ ```
275
+
276
+ ```bash
277
+ PLAYWRIGHT_MCP_ISOLATED=true PLAYWRIGHT_MCP_STORAGE_STATE=./auth.json npx mcp-accessibility-scanner
278
+ ```
279
+
280
+ ```json
281
+ {
282
+ "browser": {
283
+ "isolated": true,
284
+ "contextOptions": {
285
+ "storageState": "./auth.json"
286
+ }
287
+ }
288
+ }
289
+ ```
290
+
291
+ > **Every supported mode handles the state — by applying it or refusing it.**
292
+ >
293
+ > - **Fresh-context modes** (`--isolated`, the remote-endpoint mode, or either CDP mode combined with `--isolated`): the context is created with the storage state directly.
294
+ > - **Default persistent-profile mode with `--storage-state`**: the session runs in a fresh, disposable profile — unique to that session and removed when it closes — built from the state, so the recorded state is provably the only session data (without `--storage-state` the regular persistent profile is used and survives restarts, as before). Any page the launch opened (for example from a URL in `browser.launchOptions.args`) is parked on a blank replacement before the state lands, then the replacement is navigated to the same URL, so a still-running anonymous page cannot overwrite the recorded identity and a scan never reads its DOM. This also means `--storage-state` cannot be combined with `--user-data-dir` (a user-supplied profile carries its own session and will not be wiped; the server refuses the combination).
295
+ > - **CDP modes without `--isolated`**: the state is installed into the browser's existing context with Playwright's `setStorageState()`. Cookies are fully reset; origin storage (localStorage/IndexedDB) is reset for the origins recorded in the state *plus* any origins the Playwright connection has already seen — including pages open in the attached browser at connect time, whose storage can therefore be cleared even when the state omits them. Only origins from the profile's earlier history that this connection never saw survive untouched — cut in both directions, so treat an attached browser's storage as neither fully preserved nor fully reset, and add `--isolated` when you need a clean, fully-defined session. Pages already open in the attached browser are replaced with fresh tabs navigated to the same URLs so a scan never sees the previous identity's UI — and the old pages close *before* the state is installed, because a still-running page could otherwise persist the previous identity back into the freshly applied cookies or localStorage, which no later tab replacement could undo. A fresh tab also starts with empty per-tab `sessionStorage` (which sits outside Playwright storage states and would survive an in-place reload, where the old page's own scripts could even write the previous identity back between a clear and the reload), a replacement that fails to load is left blank or closed rather than left on a stale document, and these navigations run under a configured `--allowed-origins`/`--blocked-origins` policy just like every later navigation. The state is applied once per shared context: concurrent MCP sessions attached without `--isolated` share the browser's context, so a session joining while another is active inherits that context's live state (including anything the first session changed or cleared) rather than a fresh copy of the recorded file — add `--isolated` when every session must start from the recorded baseline.
296
+ > - **`--extension`** (with or without `--isolated`) is the one exception: it works through the browser you are already running, where wiping cookies to install a recorded state is not an acceptable side effect, so the server refuses to start rather than doing that silently. There, sign in interactively instead — the persistent profile also keeps the session across restarts.
297
+
298
+ ### Keep the crawl from destroying its own session
299
+
300
+ `audit_site` excludes `logout|signout` by default, which is not enough for most applications. Add anything else that ends or changes the session before you start the crawl:
301
+
302
+ ```json
303
+ {
304
+ "excludePathPatterns": ["logout|signout", "account/(close|delete)", "sessions/revoke", "/switch-(locale|account|org)"]
305
+ }
306
+ ```
307
+
308
+ Note that `excludePathPatterns` replaces the default rather than extending it, so repeat `logout|signout` in your list.
309
+
310
+ If a session cookie disappears anyway, `audit_site` says so instead of reporting a confident, wrong audit: the result starts with a `WARNING: cookie(s) … disappeared while loading <url>` line, and both the JSON report and the structured content carry a `sessionLosses` list naming, for each lost cookie, the page that dropped it — the page reached after any redirect, and reported even when that page failed to finish loading. If one of the lost cookies was the session, every page scanned after that point was audited as a signed-out user — exclude the offending URL, sign in again, and re-run.
311
+
312
+ The check compares which cookies the crawled URLs carry, not their values, so a rotating CSRF token never reads as a lost session. A cookie the browser deleted at its own stated expiry is ignored for the same reason — Cloudflare's `__cf_bm` lives 30 minutes and would otherwise warn on any longer crawl. Beyond that no attempt is made to tell an authentication cookie from any other: nothing in a cookie marks it as one, so any cookie the crawl started with and later lost is reported. Monitoring does not stop at the first loss — each cookie is reported once, at the URL where it vanished, so an analytics cookie expiring early cannot mask the session cookie being dropped later. URLs discovered mid-crawl join the cookie tracking before they are visited, so a session cookie scoped to a path below the start URL (say `/app`) is watched too.
233
313
 
234
314
  ## Available Tools
235
315
 
@@ -242,11 +322,57 @@ Performs a comprehensive accessibility scan on the current page using Axe-core.
242
322
 
243
323
  **Parameters:**
244
324
  - `violationsTag`: Array of WCAG/violation tags to check
325
+ - `includeIncomplete` (default `true`): also report Axe "incomplete" results
326
+ - `maxNodesPerViolation` (default `10`): cap on nodes reported per rule
327
+ - `includeSelectors` / `excludeSelectors`: CSS selectors that scope the scan
328
+ - `withRules` / `disableRules`: Axe rule ids that narrow which rules run
329
+ - `annotateScreenshot` (default `false`): capture an annotated screenshot of the violations
330
+
331
+ **Annotated screenshots:**
332
+ When `annotateScreenshot` is `true`, each violating element is outlined and labelled with the rule ids it failed, a full-page PNG is written to the MCP output directory (`scan-page-annotated-{timestamp}-{token}.png`) and returned as a resource link, and the markers are then removed so the page is left exactly as it was. The markers are drawn in an out-of-flow overlay clipped to each element's own box, so they never reflow the page. The overlay uses a fresh id per scan, is placed in the browser's top layer so it stays visible over an open dialog, popover or fullscreen element, and compensates for a CSS `zoom` or a scaled ancestor so markers line up with what is rendered.
333
+ An element that fails several rules gets one box listing every rule id, and elements inside open shadow roots are marked by walking the shadow path Axe reports.
334
+ Running animations are paused before the elements are measured and resumed after the capture, so a moving target keeps its marker. The markers themselves live in a shadow root under an overlay whose own styles are `!important`, so page CSS cannot restyle or hide what the report counts, and each rule label sits outside the clipped box so it stays readable on an element smaller than its own label.
335
+ At most 50 elements are annotated per scan. The result text always reports how many nodes were marked out of the total, plus how many were left out because they exceeded the limit, were hidden, zero-size or off-canvas (a full-page screenshot is clipped to the document box), or were inside an iframe (cross-frame selectors cannot be resolved from the top document).
245
336
 
246
337
  **Supported Violation Tags:**
247
- - WCAG standards: `wcag2a`, `wcag2aa`, `wcag2aaa`, `wcag21a`, `wcag21aa`, `wcag21aaa`, `wcag22a`, `wcag22aa`, `wcag22aaa`
248
- - Section 508: `section508`
249
- - Categories: `cat.aria`, `cat.color`, `cat.forms`, `cat.keyboard`, `cat.language`, `cat.name-role-value`, `cat.parsing`, `cat.semantics`, `cat.sensory-and-visual-cues`, `cat.structure`, `cat.tables`, `cat.text-alternatives`, `cat.time-and-media`
338
+ - WCAG standards (in the default set): `wcag2a`, `wcag2aa`, `wcag2aaa`, `wcag21a`, `wcag21aa`, `wcag21aaa`, `wcag22a`, `wcag22aa`, `wcag22aaa`
339
+ - Section 508 (in the default set): `section508`
340
+ - Categories (opt-in): `cat.aria`, `cat.color`, `cat.forms`, `cat.keyboard`, `cat.language`, `cat.name-role-value`, `cat.parsing`, `cat.semantics`, `cat.sensory-and-visual-cues`, `cat.structure`, `cat.tables`, `cat.text-alternatives`, `cat.time-and-media`
341
+ - Non-conformance tags (opt-in): `best-practice`, `experimental` (see the caveat below -- a few experimental rules also carry a WCAG tag and run by default)
342
+
343
+ The default set is the WCAG and Section 508 tags only, so a default report means "this fails a conformance criterion". Category tags are opt-in for that reason: Axe matches requested tags with OR, so asking for `cat.keyboard` also pulls in best-practice rules such as `region` and `skip-link` that carry both tags. No live conformance rule is lost by leaving them out: the only rules reachable *only* through a `cat.*` tag are `duplicate-id` and `duplicate-id-active`, which Axe marks deprecated because WCAG removed SC 4.1.1. Add `best-practice` (landmark structure, heading order, `tabindex` hygiene) or a `cat.*` tag when you want that broader review.
344
+
345
+ The same OR semantics apply to `experimental`, with one deliberate exception: five experimental rules -- `css-orientation-lock` (SC 1.3.4), `label-content-name-mismatch` (SC 2.5.3), `p-as-heading`, `table-fake-caption` and `td-has-header` (SC 1.3.1) -- also carry a `wcag*` tag and so run in the default set. In Axe, `experimental` describes how settled the heuristic is, not whether the criterion is real, so these are kept rather than filtered out. Adding the `experimental` tag pulls in the remaining experimental rules, which have no conformance tag of their own.
346
+
347
+ **Scan scoping:**
348
+ `scan_page`, `audit_site`, and `scan_page_matrix` accept `includeSelectors` and `excludeSelectors` to limit what Axe looks at. Use `includeSelectors` to audit one component (`["#checkout-form"]`) and `excludeSelectors` to drop third-party noise that pollutes every report (`["#cookie-banner", "iframe.intercom-frame"]`). Exclusions are applied after inclusions, so you can carve a widget out of an included subtree.
349
+
350
+ Selectors are resolved before the scan runs:
351
+ - Syntactically invalid CSS fails the scan, naming the selector.
352
+ - An `includeSelectors` entry that matches nothing fails the scan. Axe on its own would accept a partly-matching include set and quietly scan less than you asked for, so the scanner refuses rather than returning a clean-looking report with half the scope missing.
353
+ - An `excludeSelectors` entry that matches nothing is a no-op, not an error -- a crawl legitimately visits pages that lack the excluded widget.
354
+
355
+ In `audit_site`, selectors apply to every crawled page, so an `includeSelectors` value that is absent from a given page marks *that page* as errored in the report while the crawl continues. Link discovery runs before the scan, so pages reachable only through an errored page are still crawled.
356
+
357
+ **Rule-level control:**
358
+ `scan_page`, `audit_site`, and `scan_page_matrix` accept `withRules` and `disableRules` to pick individual Axe rules instead of whole tag sets. Use `withRules` to re-check one rule after a fix (`["color-contrast"]`) and `disableRules` to mute a rule you have already triaged (`["region"]`). Rule ids are the ones Axe reports (`image-alt`, `color-contrast`, ...); see the [Deque rule reference](https://dequeuniversity.com/rules/axe/).
359
+
360
+ - **`withRules` overrides `violationsTag`.** Axe can run either a rule list or a tag list, never both, so when `withRules` is set the tags are ignored entirely -- `withRules: ["image-alt"]` runs exactly that one rule regardless of `violationsTag`. Rule ids are the more specific request, so they win.
361
+ - **`disableRules` subtracts from whatever is selected.** It applies to `violationsTag` and `withRules` alike. (Axe itself ignores disabled rules once you give it an explicit rule list; the scanner subtracts them up front so the two options mean the same thing together as apart.) Disabling every rule in `withRules` is an error rather than an empty scan.
362
+ - **An explicitly empty `withRules` is an error too.** `withRules: []` selects no rules, and silently falling back to the tag set would run a different scan than the one requested — omit the option to scan by tags instead. Clients that build the list dynamically should drop the key when the list comes out empty.
363
+ - **Unknown rule ids fail the scan, naming the id.** Both options are checked against Axe's rule catalogue before the browser is touched, so a typo is reported as `Unknown Axe rule id(s) in withRules: image-altt` rather than surfacing later as an `frame.evaluate` failure from inside the page. Rule ids apply to a whole run, so `audit_site` and `scan_page_matrix` check them once before they touch the page -- a bad id fails the call outright instead of crawling every URL, or reloading and re-emulating the page, before rejecting the argument.
364
+
365
+ `audit_site` and `scan_page_matrix` record both values in their JSON report metadata, so a stored report can be told apart from a full scan.
366
+
367
+ **Incomplete ("needs review") results:**
368
+ Axe returns `incomplete` for checks it cannot decide on its own -- contrast over a background image or gradient, ambiguous labels, elements it could not fully evaluate. `scan_page`, `audit_site`, and `scan_page_matrix` report these in a section separate from violations so you can resolve them by inspecting the page (screenshot, snapshot, `browser_evaluate`). Set `includeIncomplete: false` to suppress them.
369
+
370
+ **Frames that could not be scanned:**
371
+ Axe is installed into every frame of the page before the scan runs. A frame that navigates mid-injection, or whose renderer does not answer within a second, is left out -- and its contents then contribute no findings. Rather than let that pass as a clean result, all three scan tools print a `WARNING: Axe could not be installed in N frame(s)` block listing the frame URLs, and `audit_site` and `scan_page_matrix` also record them per page and per variant in their JSON reports (`unscannedFrames`) and in `structuredContent`. A frame that was still loading usually succeeds on a re-run; one that fails consistently has to be audited on its own.
372
+
373
+ A nested frame is reported when any frame above it went unscanned, even if its own injection succeeded: Axe reaches a nested document only by relaying through the frames above it, so an outer frame without Axe takes everything below it out of the scan.
374
+
375
+ A frame you scoped out yourself is not reported: with `excludeSelectors: ["iframe.intercom-frame"]` that widget failing to load is the outcome you asked for, not a gap. Scope is resolved through the whole frame chain and across shadow boundaries, so an `includeSelectors` entry naming an ancestor still covers frames nested several levels below it or inside a shadow root, and excluding an outer frame or a shadow host silences everything inside it. Anything the check cannot resolve is reported rather than hidden.
250
376
 
251
377
  ### Audit Tools
252
378
 
@@ -254,7 +380,8 @@ Performs a comprehensive accessibility scan on the current page using Axe-core.
254
380
  Crawls and scans multiple internal pages, then aggregates violations across the site.
255
381
  - Default strategy: link-based BFS from the current URL
256
382
  - Supports `links`, `nav`, `sitemap`, and `provided` URL strategies
257
- - Always writes a JSON report (default filename: `audit-site-{timestamp}.json`)
383
+ - Always writes a JSON report (default filename: `audit-site-{timestamp}-{token}.json`)
384
+ - Warns and records `sessionLosses` if the crawl loses cookies it started with — see [Auditing pages behind a login](#auditing-pages-behind-a-login)
258
385
 
259
386
  **Example flow:**
260
387
  ```text
@@ -267,7 +394,8 @@ Crawls and scans multiple internal pages, then aggregates violations across the
267
394
  Runs Axe scans on the same page across viewport/media/zoom variants and compares deltas against baseline.
268
395
  - Default variants: baseline, mobile, desktop, forced-colors, reduced-motion, zoom-200
269
396
  - Supports custom variants and optional reload between variants
270
- - Always writes a JSON report (default filename: `scan-matrix-{timestamp}.json`)
397
+ - Always writes a JSON report (default filename: `scan-matrix-{timestamp}-{token}.json`)
398
+ - JSON report and structured result schema `v2` set baseline deltas to `null` when either scan left frames unscanned, because their coverage is not comparable
271
399
 
272
400
  **Example flow:**
273
401
  ```text
@@ -279,8 +407,31 @@ Runs Axe scans on the same page across viewport/media/zoom variants and compares
279
407
  #### `audit_keyboard`
280
408
  Audits real keyboard focus behavior by pressing Tab (and optional Shift+Tab) with practical heuristics.
281
409
  - Checks skip links, focus visibility, focus jumps, and possible focus traps
410
+ - Checks target size against WCAG 2.2 SC 2.5.8 (`checkTargetSize`, default on)
411
+ - Checks that focus is not entirely obscured, WCAG 2.2 SC 2.4.11 (`checkFocusObscured`, default on)
282
412
  - Optional issue screenshots (`screenshotOnIssue`)
283
- - Always writes a JSON report (default filename: `audit-keyboard-{timestamp}.json`)
413
+ - Always writes a JSON report (default filename: `audit-keyboard-{timestamp}-{token}.json`)
414
+
415
+ **Limits of the WCAG 2.2 checks** — these are heuristics, not a conformance verdict:
416
+ - Target size only inspects elements the tab order actually reaches, so pointer-only targets are never measured.
417
+ - Of the SC 2.5.8 exceptions, only *spacing* (a 24px-diameter circle centered on the target must reach neither another
418
+ target's box nor another undersized target's circle) and *inline* (an inline-level target — `inline`, `inline-block`,
419
+ `inline-flex`, … — inside surrounding sentence text, found by walking out through inline wrappers such as `<strong>`
420
+ to the containing block) are evaluated. The *user agent control*, *essential*, and *equivalent* exceptions cannot be
421
+ detected from the DOM, so a target relying on one of them is still reported and needs manual triage.
422
+ - Spacing neighbours use the same pointer-target rule as the focused element, so rendered `:disabled` controls are not
423
+ counted as neighbours, and `contenteditable` regions are counted as targets on both sides.
424
+ - Target size uses the element's bounding box, so an inline target wrapped over several lines is measured as one
425
+ union box rather than per line, and a target whose visible area is cut down by an `overflow` or `clip-path` ancestor
426
+ is measured at its full unclipped size.
427
+ - SC 2.4.11 is the Minimum (AA) level: a focused element is only reported when *every* sampled point of its box is
428
+ covered by other content. Partially covered focus passes here, and the stricter SC 2.4.12 (AAA) is not checked.
429
+ It applies to every focus stop with a rendered box, including elements that are not pointer targets such as iframes.
430
+ - Coverage is measured by hit-testing sample points and then checking that the element hit actually paints (visible,
431
+ non-zero opacity all the way up to the first wrapper shared with the focused element, non-transparent background or
432
+ background image). A transparent click-catching overlay therefore does *not* count as obscuration, but a covering
433
+ layer with `pointer-events: none` is never returned by hit testing and is missed. Semi-transparent overlays that
434
+ still leave content legible are reported.
284
435
 
285
436
  **Example flow:**
286
437
  ```text
@@ -289,6 +440,37 @@ Audits real keyboard focus behavior by pressing Tab (and optional Shift+Tab) wit
289
440
  3. Review focus findings and open the generated JSON report path
290
441
  ```
291
442
 
443
+ #### `audit_screen_reader`
444
+ Audits what a screen reader actually announces, using the browser's own accessibility tree (`page.ariaSnapshot`) plus element geometry. No screen reader is installed or driven; this is a static reading of the exposed tree.
445
+
446
+ **Checks (`checkNames`)**
447
+ - `missing-accessible-name`: controls and images exposed with no accessible name (WCAG 4.1.2)
448
+ - `uninformative-accessible-name`: names such as "click here", "read more", "image" that mean nothing out of context (WCAG 2.4.4)
449
+ - `filename-as-accessible-name`: image alt text that is a file name, e.g. `IMG_1234.jpg`, `DSC00123` (WCAG 1.1.1). Only images are checked: a link or button legitimately named after the file it downloads (`logo.png`) is not a defect.
450
+ - `label-in-name-mismatch`: the accessible name does not contain the visible label, which breaks voice control (WCAG 2.5.3). The visible label of `<input type="submit|button|reset">` is read from its `value`, and a web component's label is read from its open shadow root.
451
+ - `duplicate-accessible-name`: sibling links with the same name that lead to different URLs (WCAG 2.4.4)
452
+
453
+ **Check (`checkReadingOrder`)**
454
+ - `reading-order-mismatch`: accessibility tree order (what is read) versus visual position (WCAG 1.3.2), i.e. `order`, `flex-direction: row-reverse`, absolute positioning
455
+
456
+ **What it deliberately does not detect**
457
+ - Reading order is only compared between siblings that form a single row or a single column. Genuine two-dimensional layouts (grid, CSS multi-column, wrapped flex) have no single correct linear order and are skipped rather than guessed.
458
+ - Elements are excluded from the reading-order comparison when they render no text, are `aria-hidden`, floated, `position: fixed`, off-canvas, or clipped to 1px, because their visual position is decoupled from source order by design. Tolerance: two boxes count as swapped only when they are fully separated along the compared axis (1px), and right-to-left containers are compared right-to-left.
459
+ - Duplicate names are only reported when the destinations differ *and* are observable, which today means resolved link URLs (`/help` and `https://site/help` are the same destination). Two `Save` submit buttons in one form are never called ambiguous, because nothing in the exposed tree says whether they do different things.
460
+ - Only elements the AI snapshot gives a `ref` are analyzed, and Playwright refs the elements that are visible and receive pointer events. A control that is announced but not interactable (`pointer-events: none`, some off-canvas widgets) is therefore skipped: without a ref it cannot be measured, so neither its `aria-hidden` state nor a selector to fix it can be established, and reporting it would mostly surface decorative `aria-hidden` icons.
461
+ - Heading levels and landmark structure are not checked; axe already reports those (`heading-order`, `region`, `landmark-one-main`), so use `scan_page` for them.
462
+ - Findings for names overlap with axe rules such as `link-name`, `button-name` and `image-alt`; this tool adds the quality checks (generic names, file names, label-in-name, duplicates) that axe cannot make.
463
+ - It reports the page as currently rendered. Content behind a collapsed panel or another viewport is judged in that state.
464
+
465
+ **Bounds:** `maxElements` (default 400) caps how many *screen-reader-reachable* accessibility tree elements are analyzed. The snapshot also refs `aria-hidden` subtrees, which no check reports, so measuring continues past them until the budget is filled with reachable elements (up to a hard ceiling of twice `maxElements` measured, so a page built mostly of hidden refs stays bounded). `maxFindingsPerCheck` (default 20) caps the findings listed per check. Both truncations are stated in the summary and the JSON report, and the full counts are always reported. Always writes a JSON report (default filename: `audit-screen-reader-{timestamp}-{token}.json`).
466
+
467
+ **Example flow:**
468
+ ```text
469
+ 1. Navigate to the target page and let it fully load
470
+ 2. Run audit_screen_reader (optionally raise maxElements for a large page)
471
+ 3. Fix the reported elements by ref, then re-run to confirm
472
+ ```
473
+
292
474
  ### Navigation Tools
293
475
 
294
476
  #### `browser_navigate`
@@ -337,6 +519,13 @@ Hover over element on page.
337
519
  Perform drag and drop between two elements.
338
520
  - Parameters: `startElement`, `startRef`, `endElement`, `endRef`
339
521
 
522
+ #### `browser_drop`
523
+ Simulate an external drag and drop of files or clipboard-like data onto an element, for testing drop zones that never see a drag start inside the page.
524
+ - Parameters: `element`, `ref`, `paths` (optional array of absolute file paths), `data` (optional map of mime type to value, e.g. `{"text/plain": "hello"}`)
525
+ - At least one of `paths` or `data` is required.
526
+ - Fails if the target's `dragover` handler does not accept the payload.
527
+ - `paths` are read from the filesystem of the machine running the server, exactly as `browser_file_upload` does, and a relative path resolves against the server's working directory. Unlike `browser_file_upload` this needs no file chooser to be open, so any page with a `dragover` handler is a valid target — treat it as a tool that can hand local file contents to the page.
528
+
340
529
  #### `browser_select_option`
341
530
  Select an option in a dropdown.
342
531
  - Parameters: `element`, `ref`, `values` (array)
@@ -352,17 +541,20 @@ Press a key on the keyboard.
352
541
  #### `browser_evaluate`
353
542
  Evaluate a JavaScript expression on the page, or on a specific element when a `ref` is provided. The function's return value is serialized back as the result.
354
543
  - Parameters: `function` (e.g., `() => document.title` or `(element) => element.textContent`), `element` (optional), `ref` (optional)
544
+ - `element` and `ref` must be supplied together, or not at all; supplying one without the other is rejected.
545
+ - A bare expression is also accepted and is wrapped automatically: `document.title` behaves like `() => document.title`, and, when `element` and `ref` are both given, `element.textContent` behaves like `(element) => element.textContent`. The parameter is always named `element`.
546
+ - Whether the input is a function or an expression is decided from its source form, never from what it evaluates to, so an expression such as `window.open` is returned rather than called.
355
547
 
356
548
  ### Screenshot & Visual Tools
357
549
 
358
550
  #### `browser_take_screenshot`
359
551
  Take a screenshot of the current page.
360
- - Parameters: `filename` (optional), `type` (`png` or `jpeg`), `scale` (`css` or `device`, default `css`), `fullPage` (optional), `element`/`ref` pair (for element screenshots)
552
+ - Parameters: `filename` (optional), `type` (`png`, `jpeg`, or `webp`), `scale` (`css` or `device`, default `css`), `fullPage` (optional), `element`/`ref` pair (for element screenshots)
361
553
  - `scale: device` captures a high-resolution screenshot using device pixels (accounts for the device pixel ratio); `scale: css` keeps the image sized in CSS pixels.
362
554
 
363
555
  #### `browser_pdf_save`
364
556
  Save page as PDF.
365
- - Parameters: `filename` (optional, defaults to `page-{timestamp}.pdf`)
557
+ - Parameters: `filename` (optional, defaults to `page-{timestamp}-{token}.pdf`)
366
558
 
367
559
  This tool requires `--caps pdf` in the CLI.
368
560
 
@@ -385,6 +577,29 @@ Resize the browser window.
385
577
  Manage browser tabs in one tool.
386
578
  - Parameters: `action` (`list`, `new`, `close`, `select`) and optional `index` (for `close` and `select`).
387
579
 
580
+ ### Browser Session Tools
581
+
582
+ Following the MCP 2026-07-28 stateless prescription, browser state can be named by an explicit server-minted handle instead of living implicitly in the connection. Every browser tool except the two session tools accepts an optional `browserSessionId` argument; when it is omitted, the tool runs in the default session and behaves exactly as before.
583
+
584
+ #### `browser_session_open`
585
+ Opens a separate browser session — its own browser context with its own tabs, cookies and storage — and returns its opaque handle (`bs_...`) both in the result text and as `structuredContent.browserSessionId`. Pass that handle as the `browserSessionId` argument of other browser tools to run them in this session.
586
+
587
+ How the separate context is provided depends on the mode:
588
+
589
+ - **Default persistent-profile mode**: each session runs in its own fresh, disposable profile (removed when the session closes or expires); only the default session uses the stable persistent profile, whose sign-in state keeps surviving restarts. This is required — one profile directory can back only one running browser at a time.
590
+ - **`--isolated`, remote endpoints, and CDP/`--cdp-launch` with `--isolated`**: each session gets its own fresh browser context. In `--cdp-launch` mode each context launches its own instance of the configured application on its own free port — which is why combining `--cdp-launch-port` with `--isolated` also rejects `browser_session_open`: a pinned port can serve only one launched instance, so a second session would silently attach to the first session's application. A second concurrent browser context on the pinned port (e.g. from a parallel client) is likewise rejected with an error rather than attaching to the first context's application.
591
+ - **Modes that reuse one live browser context** — CDP attach or `--cdp-launch` without `--isolated`, `--extension`, the VS Code bridge, and servers created with a custom context getter — cannot create a separate context, so `browser_session_open` is rejected with an explanation instead of handing out a handle that would share the same tabs, cookies and storage as everything else. The same applies in the default mode when `--user-data-dir` pins all browsing to one user-supplied profile.
592
+
593
+ In `--vscode` serving, browser sessions are host-scoped: `browser_session_open`, `browser_session_close`, and every call carrying a `browserSessionId` always run against the default provider's session registry at the host, regardless of any `browser_connect` provider switch. A handle opened before a switch keeps working (and can be closed) while the proxy is switched to a VS Code-connected browser, and a session opened while switched is created by the default provider — the VS Code-connected browser itself reuses one live context and cannot host separate sessions. Only session-less tool calls follow the switch.
594
+
595
+ #### `browser_session_close`
596
+ Closes a session opened with `browser_session_open` and releases its browser resources.
597
+ - Parameters: `browserSessionId` (the handle to close)
598
+
599
+ Closing is refused with a tool error while a tool call is still running in that session — a close that disposed the browser mid-call would fail the running tool; wait for it to finish and retry.
600
+
601
+ Sessions that stay idle expire automatically after 30 minutes; the timer is refreshed on every use and while a tool is running in the session (overlapping calls each count, so the session survives until the last one finishes), so a long `audit_site` crawl is never expired mid-run. Set `PLAYWRIGHT_MCP_BROWSER_SESSION_TTL_MS` to override the idle TTL in milliseconds (`0` or a negative value disables expiry). Passing an unknown or expired handle produces a tool error pointing back to `browser_session_open`; the error deliberately does not list other open sessions' handles, since handles are bearer tokens that route tool calls into their sessions. With `--save-session`, logged tool calls record the `browserSessionId` they were routed with, and recorded user actions from an explicit session carry the same `browserSessionId` in their logged args, so entries from different sessions stay distinguishable (default-session entries stay untagged).
602
+
388
603
  ### Information & Monitoring Tools
389
604
 
390
605
  #### `browser_console_messages`
@@ -392,8 +607,18 @@ Returns all console messages from the page.
392
607
  Large `data:` URL payloads in console messages are truncated to their media type prefix.
393
608
 
394
609
  #### `browser_network_requests`
395
- Returns all network requests since loading the page.
610
+ Returns all network requests since loading the page, numbered so a single one can be inspected with `browser_network_request`.
396
611
  Large `data:` URL payloads in request URLs are truncated to their media type prefix.
612
+ When there is at least one request, a closing line points at `browser_network_request`.
613
+
614
+ #### `browser_network_request`
615
+ Returns credential-redacted request/response headers and body metadata for one request from the `browser_network_requests` listing.
616
+ - Parameters: `index` (the number shown in the listing, starting at 1)
617
+ - The listing is cleared by `browser_navigate` and when the tab closes; other navigations (link clicks, form submits, `history` calls) leave it in place and keep appending. Re-run `browser_network_requests` to get current indexes.
618
+ - Credential-bearing headers (`authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token`) are reported as `<redacted, N characters>`, so their presence and size stay visible but the secret never reaches the transcript. All other headers are reported in full, one line each.
619
+ - Request and response body contents are never returned because they can contain submitted credentials or private API data. Non-empty bodies are reported as `<redacted, N bytes, mime/type>`; empty bodies remain `<empty>`.
620
+ - A request that failed after its response arrived reports both the status and the failure.
621
+ - Sections that could not be read are reported in place (`<headers unavailable: ...>`, `<body unavailable: ...>`) rather than failing the whole call; reads are bounded by the default timeout, so a still-streaming response cannot hang the tool.
397
622
 
398
623
  ### Utility Tools
399
624
 
@@ -404,6 +629,7 @@ Wait for text to appear/disappear or time to pass.
404
629
  #### `browser_handle_dialog`
405
630
  Handle browser dialogs (alerts, confirms, prompts).
406
631
  - Parameters: `accept` (boolean), `promptText` (optional)
632
+ - If the dialog was already closed outside the session (e.g. dismissed manually in a headed browser), the call succeeds, reports the dialog as already closed, and clears its leftover state instead of failing.
407
633
 
408
634
  #### `browser_file_upload`
409
635
  Upload files to the page.
@@ -504,6 +730,33 @@ cd mcp-accessibility-scanner
504
730
  npm install
505
731
  ```
506
732
 
733
+ ### Benchmarking tool latency
734
+
735
+ `bench/mcp-bench.mjs` measures what a client actually waits for: it serves a fixed
736
+ synthetic site, speaks MCP to the built server over stdio, and times real
737
+ `tools/call` round trips for navigation, interaction, snapshots and every audit
738
+ tool. Build first — it runs the compiled server from `lib/`.
739
+
740
+ ```bash
741
+ npm run build
742
+ npm run bench -- --out after.json --label after
743
+ ```
744
+
745
+ Useful flags: `--iterations <n>` and `--warmups <n>` (defaults 5 and 1),
746
+ `--browser`/`--executable-path` when the browser lives outside Playwright's own
747
+ download directory, and `--server <path/to/cli.js>` plus `--lib <path/to/lib>`
748
+ (supplied together) to point at a different build — that is how revisions compare:
749
+
750
+ ```bash
751
+ git worktree add /tmp/baseline main && (cd /tmp/baseline && npm install && npm run build)
752
+ npm run bench -- --server /tmp/baseline/cli.js --lib /tmp/baseline/lib --out before.json --label before
753
+ npm run bench -- --out after.json --label after
754
+ npm run bench:compare -- before.json after.json
755
+ ```
756
+
757
+ The comparison total uses only end-to-end scenarios present in both reports,
758
+ so adding or removing a scenario does not distort the reported speedup.
759
+
507
760
  ## License
508
761
 
509
762
  MIT
package/index.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
17
+ import type { Server } from '@modelcontextprotocol/server';
18
18
  import type { Config } from './config.js';
19
19
  import type { BrowserContext } from 'playwright';
20
20