Skip to content

feat: accessibility tree#64

Merged
angiejones merged 3 commits intomainfrom
feature/page-context-resources
Feb 22, 2026
Merged

feat: accessibility tree#64
angiejones merged 3 commits intomainfrom
feature/page-context-resources

Conversation

@angiejones
Copy link
Copy Markdown
Owner

@angiejones angiejones commented Feb 22, 2026

Fixes #62 and #63

Introduces a new accessibility snapshot resource to enable clients to retrieve a structured representation of a page's interactive elements, improving automation and accessibility analysis.

Also updates codebase to use MCP SDK 1.26.0

Summary by CodeRabbit

  • New Features

    • Accessibility snapshot resource to retrieve the page's accessibility tree; new CLI entry and browser-side accessibility script.
  • Tests

    • Added resource/integration tests and client methods to list/read resources.
  • Chores

    • Bumped package version to 0.2.1 and updated core SDK dependency.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 22, 2026

📝 Walkthrough

Walkthrough

Adds an accessibility snapshot resource and in-page DOM walker, refactors server tool/resource registration to schema-driven APIs, exposes resource listing/reading in test client, updates tests for resources, bumps package/dependency, narrows a .gitignore rule, and duplicates a README Resources block.

Changes

Cohort / File(s) Summary
Config & Package
/.gitignore, package.json
Narrowed ignore rule from lib//lib/; bumped package version 0.2.0→0.2.1 and @modelcontextprotocol/sdk ^1.7.0→^1.26.0.
Docs
AGENTS.md, README.md
Documentation updated to reflect new CLI and architecture; README.md has a duplicated Resources section.
Accessibility Walker
src/lib/accessibility-snapshot.js
New browser-side script that walks DOM to build an accessibility tree (role mapping, name resolution, truncation, skip rules) and returns JSON.
Server: Tools & Resources
src/lib/server.js
Refactored tool registration calls to server.registerTool(...) with descriptions/input schemas; added accessibility-snapshot resource via server.registerResource(...); loads accessibility script from disk and executes it via WebDriver; introduces McpError/ErrorCode usage.
Client Test Utilities
test/mcp-client.mjs
Added listResources() and readResource(uri) methods to call resources/list and resources/read.
Tests
test/resources.test.mjs, test/server.test.mjs, test/navigation.test.mjs
Added resources.test.mjs validating accessibility snapshot contents and error cases; updated server.test.mjs to assert registered resources; simplified invalid-locator assertion in navigation.test.mjs.
CLI / Examples
bin/mcp-selenium.js, AGENTS.md mapping
New CLI entrypoint referenced in docs and file map updated to include it.

Sequence Diagram

sequenceDiagram
    actor Client
    participant Server
    participant Browser
    participant DOM

    Client->>Server: resources/read("accessibility://current")
    activate Server
    Server->>Browser: executeScript(accessibilitySnapshotScript)
    activate Browser
    Browser->>DOM: walk(document.body)
    activate DOM
    DOM->>DOM: traverse nodes and build tree
    DOM-->>Browser: accessibility tree (JSON)
    deactivate DOM
    Browser-->>Server: script result
    deactivate Browser
    Server-->>Client: JSON payload or McpError
    deactivate Server
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped through nodes and named each tree,
Roles and texts now sing back to me,
I scurried, trimmed, and made a map,
Stored as a resource — no messy chat,
Tiny rabbit, proud and snappy.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes one out-of-scope change: a duplicated Resources section in README.md that mirrors existing content with no added value, unrelated to the accessibility tree feature or linked issues. Remove the duplicated Resources section from README.md to keep the documentation clean and focused on the implemented accessibility tree feature.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: accessibility tree' directly reflects the primary change—adding an accessibility snapshot resource to expose page structure as requested in issues #62 and #63.
Linked Issues check ✅ Passed The PR implements the accessibility snapshot resource (#62) with a JavaScript-based tree walker (accessibility-snapshot.js), adds the accessibility://current resource endpoint in server.js, updates SDK to 1.26.0, and includes comprehensive tests for the new resource.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/page-context-resources

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/lib/server.js (1)

877-882: Consider reusing getDriver() to reduce duplication.

Lines 879-880 duplicate the session-check logic from getDriver() (lines 45-51). You could call getDriver() and let its Error be caught and wrapped by the catch block at line 883-885, which already translates non-McpError exceptions into McpError(ErrorCode.InternalError, ...). The only loss is the custom -32002 code, which could be addressed by making getDriver() throw an McpError directly — though that would couple the helper to MCP types.

This is a minor duplication with a reasonable justification if you want the custom error code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server.js` around lines 877 - 882, Replace the duplicated
session-check/driver retrieval in the accessibility snapshot handler by reusing
the existing getDriver() helper: instead of directly accessing
state.drivers.get(state.currentSession) and throwing a custom McpError, call
await getDriver() to obtain the driver and then use it to run
accessibilitySnapshotScript; this removes the duplicated logic and lets the
existing catch block convert any thrown errors (including missing-session
errors) into McpError as currently handled. Ensure you remove the manual
state.drivers/state.currentSession access and the local throw so only
getDriver() is responsible for validating the session.
test/resources.test.mjs (1)

63-74: Second McpClient instance violates the one-client-per-file guideline.

The comment explains the motivation, but the coding guideline explicitly states "use one McpClient per test file rather than spawning multiple clients per file." An alternative that avoids the extra client: tear down the browser session within this test (close_session → assert error → start_browser + navigate to restore), eliminating the need for freshClient entirely.

Based on learnings from AGENTS.md: "use one McpClient per test file rather than spawning multiple clients per file."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/resources.test.mjs` around lines 63 - 74, Replace the second McpClient
usage with operations on the existing client: instead of creating freshClient
and calling start/stop, call the session teardown method (e.g., close_session)
on the existing McpClient, perform the readResource('accessibility://current')
to assert it throws the -32002/No active browser session error, then restore the
browser session by invoking the start_browser (or equivalent start/navigation)
method before continuing other tests; reference McpClient, readResource,
start/stop, close_session and start_browser to locate and change the test
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/accessibility-snapshot.js`:
- Around line 24-62: The walk function currently assumes el is non-null which
causes a crash if document.body is null; add a guard at the start of walk (and
before calling walk(document.body)) to return null when el is falsy, update
ROLE_MAP to include OPTION: 'option' so <option> nodes are represented
(preventing dropdown choices from being collapsed), and add a short comment near
the CSS-hidden checks in walk explaining that display:none / visibility:hidden
are intentionally not filtered for performance (or call out where to add
getComputedStyle if you want that behavior).

In `@src/lib/server.js`:
- Around line 870-888: Replace the magic numeric error code -32002 with the SDK
enum ErrorCode.InternalError in the McpError thrown when no active browser
session (inside the server.registerResource callback that checks
state.drivers.get(state.currentSession)); update the throw to use new
McpError(ErrorCode.InternalError, "...") and ensure ErrorCode is imported from
the MCP SDK where McpError is referenced so the enum is available.

In `@test/resources.test.mjs`:
- Around line 20-23: Wrap the call to client.callTool('close_session') inside a
try/catch within the after hook in test/resources.test.mjs so that any error
from close_session is caught (e.g., try { await client.callTool('close_session')
} catch (err) { /* optionally log */ }) and then always call await client.stop()
after the try/catch; this mirrors the guarded pattern used in
navigation.test.mjs and ensures client.stop() runs even if close_session throws.
- Line 16: The start_browser invocation via client.callTool should include the
Chromium flags used elsewhere so it runs in containerized CI; update the call to
client.callTool('start_browser', { browser: 'chrome', options: { headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'] } }) so the options object
adds the --no-sandbox and --disable-dev-shm-usage flags alongside headless.

---

Nitpick comments:
In `@src/lib/server.js`:
- Around line 877-882: Replace the duplicated session-check/driver retrieval in
the accessibility snapshot handler by reusing the existing getDriver() helper:
instead of directly accessing state.drivers.get(state.currentSession) and
throwing a custom McpError, call await getDriver() to obtain the driver and then
use it to run accessibilitySnapshotScript; this removes the duplicated logic and
lets the existing catch block convert any thrown errors (including
missing-session errors) into McpError as currently handled. Ensure you remove
the manual state.drivers/state.currentSession access and the local throw so only
getDriver() is responsible for validating the session.

In `@test/resources.test.mjs`:
- Around line 63-74: Replace the second McpClient usage with operations on the
existing client: instead of creating freshClient and calling start/stop, call
the session teardown method (e.g., close_session) on the existing McpClient,
perform the readResource('accessibility://current') to assert it throws the
-32002/No active browser session error, then restore the browser session by
invoking the start_browser (or equivalent start/navigation) method before
continuing other tests; reference McpClient, readResource, start/stop,
close_session and start_browser to locate and change the test accordingly.

Comment thread src/lib/accessibility-snapshot.js
Comment thread src/lib/server.js
Comment thread test/resources.test.mjs Outdated
Comment thread test/resources.test.mjs
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/lib/accessibility-snapshot.js (1)

46-46: Nit: prefer RegExp.prototype.test() over String.prototype.match() for boolean checks.

test() is marginally faster and conveys intent more clearly when you only need a boolean.

Suggested change
-    if (tag.match(/^H[1-6]$/)) node.level = parseInt(tag[1]);
+    if (/^H[1-6]$/.test(tag)) node.level = parseInt(tag[1]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/accessibility-snapshot.js` at line 46, Replace the boolean check
using String.prototype.match with RegExp.prototype.test for clarity and
performance: in the code that sets node.level (the if using
tag.match(/^H[1-6]$/)), change the condition to use /^H[1-6]$/.test(tag) and
keep the existing parseInt(tag[1]) assignment to node.level (optionally add
radix 10 if desired) so the intent is clearer and the check returns a boolean
directly.
src/lib/server.js (1)

870-890: Consider guarding against a null accessibility tree.

driver.executeScript(accessibilitySnapshotScript) can return null when document.body is absent or the page has no semantically meaningful content (the walk function returns null in those cases). JSON.stringify(null) produces the string "null", which is valid JSON but may surprise consumers expecting an object.

A small fallback keeps the contract predictable:

Suggested guard
             const tree = await driver.executeScript(accessibilitySnapshotScript);
-            return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(tree, null, 2) }] };
+            const result = tree ?? { role: "none", name: "(empty page)" };
+            return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(result, null, 2) }] };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server.js` around lines 870 - 890, The accessibility snapshot handler
registered in server.registerResource ("accessibility-snapshot") should guard
against driver.executeScript(accessibilitySnapshotScript) returning null; check
the result from driver.executeScript(accessibilitySnapshotScript) (in the async
callback) and if it is null/undefined replace it with a stable empty structure
(e.g., {} or an empty tree object) before calling JSON.stringify so consumers
always get a predictable JSON object rather than the literal "null". Update the
variable used to build the return (tree) accordingly and keep the existing
McpError handling unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/lib/accessibility-snapshot.js`:
- Line 46: Replace the boolean check using String.prototype.match with
RegExp.prototype.test for clarity and performance: in the code that sets
node.level (the if using tag.match(/^H[1-6]$/)), change the condition to use
/^H[1-6]$/.test(tag) and keep the existing parseInt(tag[1]) assignment to
node.level (optionally add radix 10 if desired) so the intent is clearer and the
check returns a boolean directly.

In `@src/lib/server.js`:
- Around line 870-890: The accessibility snapshot handler registered in
server.registerResource ("accessibility-snapshot") should guard against
driver.executeScript(accessibilitySnapshotScript) returning null; check the
result from driver.executeScript(accessibilitySnapshotScript) (in the async
callback) and if it is null/undefined replace it with a stable empty structure
(e.g., {} or an empty tree object) before calling JSON.stringify so consumers
always get a predictable JSON object rather than the literal "null". Update the
variable used to build the return (tree) accordingly and keep the existing
McpError handling unchanged.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/accessibility-snapshot.js`:
- Around line 16-21: Add an explicit 'hidden' mapping to INPUT_ROLES (set to
null) and short-circuit element traversal when an INPUT's resolved role is null:
update INPUT_ROLES to include 'hidden': null so ROLE_MAP['INPUT'] won't be used
for hidden inputs, then in the walk function (after resolving role for an
element, particularly for tagName 'INPUT') return early when role === null to
avoid emitting hidden inputs into the accessibility tree.

Comment on lines +16 to +21
var INPUT_ROLES = {
checkbox: 'checkbox', radio: 'radio', button: 'button',
submit: 'button', reset: 'button', range: 'slider',
search: 'searchbox', email: 'textbox', url: 'textbox',
tel: 'textbox', number: 'spinbutton'
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

<input type="hidden"> leaks into the tree as role: 'textbox'.

INPUT_ROLES has no 'hidden' entry, so hidden inputs fall through to ROLE_MAP['INPUT'] ('textbox'). They commonly carry a name attribute (e.g., csrf_token, _method), so they'll appear as { role: 'textbox', name: 'csrf_token' } — semantically wrong and noisy for LLM consumers.

🐛 Proposed fix
 var INPUT_ROLES = {
     checkbox: 'checkbox', radio: 'radio', button: 'button',
     submit: 'button', reset: 'button', range: 'slider',
-    search: 'searchbox', email: 'textbox', url: 'textbox',
-    tel: 'textbox', number: 'spinbutton'
+    search: 'searchbox', email: 'textbox', url: 'textbox',
+    tel: 'textbox', number: 'spinbutton', hidden: null
 };

Then early-exit in walk when the resolved role is explicitly null for an INPUT:

-    var role = el.getAttribute('role') || (tag === 'INPUT' ? INPUT_ROLES[el.type] : null) || ROLE_MAP[tag] || null;
+    var inputRole = tag === 'INPUT' ? INPUT_ROLES[el.type] : undefined;
+    if (inputRole === null) return null; // explicitly excluded type (e.g. hidden)
+    var role = el.getAttribute('role') || inputRole || ROLE_MAP[tag] || null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/accessibility-snapshot.js` around lines 16 - 21, Add an explicit
'hidden' mapping to INPUT_ROLES (set to null) and short-circuit element
traversal when an INPUT's resolved role is null: update INPUT_ROLES to include
'hidden': null so ROLE_MAP['INPUT'] won't be used for hidden inputs, then in the
walk function (after resolving role for an element, particularly for tagName
'INPUT') return early when role === null to avoid emitting hidden inputs into
the accessibility tree.

@angiejones angiejones merged commit ae2ea19 into main Feb 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose page source and accessibility snapshot as MCP Resources

1 participant