npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

semantic-dom-mcp

v0.8.0

Published

Local MCP server (stdio) that extracts live pages and multi-step flows into compact Semantic JSON with Playwright-native locators, observed behavior (navigations, requests) and snapshot diffs, for consistent AI-generated Playwright tests.

Downloads

309

Readme

semantic-dom-mcp

Local MCP server (stdio, Node.js + TypeScript) that drives a real Chromium browser via Playwright and gives a coding agent what it needs to write a Playwright test it did not have to guess: verified locators (counted by Playwright's own engine, scoped the way a QA engineer scopes them), structured assertion data (tables as rows and cells, dialogs as label/value pairs), the behavior the page showed (navigations, requests, console errors), and diffs between steps of a flow. Same page → same output → same conventions → same test style across the team.

How it keeps tokens low. An agent starts with an outline of the page (regions, tables, dialogs, alerts; a few hundred to a few thousand characters, smaller than the native accessibility tree), then extracts one region, and acts with the snapshot in the same call. Measured on a real seller dashboard, the outline was 82–95% smaller than Playwright's aria snapshot of the same page, and a whole authenticated add-to-cart flow ran in about 10k tokens. A full-page extraction is still 2–3x the size of the aria snapshot, because it carries what that tree does not: executable locators, uniqueness verdicts and state. Ask for the whole page only when you need it. Numbers: benchmark/RESULTS.md, FLOW-VALIDATION.md. Docs: How it works (deep dive) · Team guide (setup + connecting your agent) · Benchmark methodology · v0.5 flow validation · Roadmap · Changelog

Quickstart

No clone, no build — the package is on npm. One-time browser setup (installs the Chromium build matching the package's bundled Playwright):

npx -y -p semantic-dom-mcp playwright install chromium

Then add the server to your MCP client:

{
  "mcpServers": {
    "semantic-dom": {
      "command": "npx",
      "args": ["-y", "semantic-dom-mcp"],
      "env": {
        "QA_MCP_ALLOWED_HOSTS": "staging.yourapp.internal,staging.admin.internal",
        "QA_MCP_STORAGE_STATE": "./.auth/staging.json"
      }
    }
  }
}

That's the whole setup. Verify by asking your agent to list its MCP tools — you should see extract_semantic_dom. See docs/GUIDE.md for per-client config locations (Claude Code, Claude Desktop, Cursor, Windsurf), authenticated staging, and troubleshooting. To run from a clone instead (contributors), see Development below.

Workflow

Single page. Ask your agent: "extract the checkout page and write a success-path test."

  1. The agent calls extract_semantic_dom({ url }). The server navigates a real Chromium page, runs the extractor inside it, and returns Semantic JSON: every interactive node with a ready-to-paste Playwright locator, uniqueness verified by Playwright's own engine.
  2. The agent uses the write_playwright_test prompt (scenario + the JSON), which injects the team conventions.
  3. The result is a Playwright test in team style, grounded in real locators, never guessed ones.

Multi-step flow (v0.8 way). Ask: "write a test for login → add to cart → checkout."

  1. session_open({ url }) opens a persistent page, then session_extract({ mode: "outline" }) maps it: regions with a selector, tables with row identity and cells, dialogs, alerts.
  2. session_extract({ scope, roles, visible_only }) pulls only the region the step needs, with locators verified unique inside it.
  3. session_act({ actions, then_extract: { mode: "diff", scope } }) runs declared actions (paste any returned playwright expression as the locator) and returns, in the same call, what the page did (observed: redirects, requests, console errors) and what changed (added nodes, removed nodes, state transitions). That is the wait list and the assertion list for the step.
  4. Repeat per step, then session_verify_locators with the expressions in the written spec, then session_close.

Every fact a test needs (locator, redirect, API path, state transition, cell value) comes from the page, not from the agent's memory.

MCP surface

| Kind | Name | Purpose | | --- | --- | --- | | Tool | extract_outline | The page as a map: regions (each with a selector for scope), structured tables, open dialogs, alerts. Start here. | | Tool | extract_semantic_dom | Extract a URL into Semantic JSON (url, wait_for default auto, wait_selector, scope, roles, visible_only, max_output_chars, include_tables, include_hidden, max_nodes, viewport, include_click_targets). Read-only, never touches the page. | | Tool | verify_locators | Count every Playwright expression from a written spec against the live page; matches, uniqueness, first match, summary. | | Tool | get_conventions | The team conventions as text, for clients that hide MCP prompts. | | Tool | extract_semantic_dom_after | Same, but first runs a short declared action list (fill/click/press/select/goto/wait, max 20) in the main frame and snapshots the resulting state, plus an observed block of what the page did meanwhile. Refuses to extract if the actions navigated off the allowlist. | | Tool | session_open | Open a persistent page for a multi-step flow (url, wait_for, wait_selector, viewport). Returns a session_id. Sessions are capped and expire when idle. | | Tool | session_act | Run declared actions in an open session; then_extract returns the diff, a scoped extraction or an outline in the same call. Action locators take a returned playwright expression verbatim. Returns the resulting URL/title and observed: main-frame navigations, xhr/fetch requests (method, path, status), console errors, dialogs (dismissed), popups (closed). | | Tool | session_extract | Snapshot the session's current state (snapshot_id included), or mode: "outline". Takes scope/roles/visible_only/max_output_chars/include_tables. With diff_against (a snapshot id or "previous") returns a diff: added, removed, changed nodes and the behavior observed in between. | | Tool | session_verify_locators | verify_locators against the session's current page. | | Tool | session_close | Release the session's browser context. | | Tool | session_list | Diagnostic: open sessions with URL, expiry and counts. | | Tool | check_auth | Diagnostic: navigates with the configured storageState and reports whether the session bounced to a login-looking page (expired auth shows up as an answer, not a mystery). | | Tool | list_frames | Diagnostic frame tree with same-origin/reachability classification. | | Prompt | write_playwright_test | Team-standard test-writing prompt (scenario, extract_json, team_name?, framework_note?). | | Resource | conventions://playwright | The same team conventions as read-only text. |

Errors (navigation failure, denied host, missing selector, expired session) come back as structured JSON in the tool result, so the agent can react instead of crashing. Every tool carries MCP annotations (readOnlyHint, openWorldHint: false) so clients can auto-approve the read-only ones.

Configuration (environment variables)

| Variable | Meaning | | --- | --- | | QA_MCP_ALLOWED_HOSTS | Required. Comma-separated hostnames the server may navigate to. Navigation is denied by default. Supports host, host:port, and *.domain entries. | | QA_MCP_STORAGE_STATE | Optional path to a Playwright storageState JSON for pre-authenticated staging sessions. This file holds a live session — it is gitignored; never commit it. | | QA_MCP_TEAM_NAME | Optional team name used in the write_playwright_test prompt (default QA). | | QA_MCP_SESSION_TTL_MS | Idle time before a session is closed automatically (default 600000, 10 minutes). | | QA_MCP_MAX_SESSIONS | Max concurrently open sessions (default 3). |

Security posture

  • Tool inputs are untrusted (they arrive via an LLM): strict schemas (additionalProperties: false), http/https only, host allowlist enforced before any navigation.
  • extract_semantic_dom only reads the DOM. It never clicks, submits, or mutates the page. The sanctioned exceptions are extract_semantic_dom_after and session_act, which execute only an explicit, bounded, schema-validated action list, never log fill values, and refuse to extract if the page leaves the allowlisted hosts (a session in that state is closed).
  • Sessions add their own limits: an idle TTL, a cap on open sessions, one in-flight call per session, and a bounded snapshot history kept in memory only.
  • Behavior capture is observation only. The server never issues requests of its own. Request and response bodies are never read, query strings are stripped from recorded URLs (they may carry tokens), console text is capped, dialogs are dismissed and popups closed at once.
  • Sessions check the allowlist before any action, after every action, after a failed action and before every snapshot. A session found off the allowlist is closed on the spot.
  • No network egress beyond navigating the browser to allowlisted URLs. No telemetry. Page contents are never logged (stderr carries only high-level events) and are not stored beyond the current call or session.

Semantics worth knowing

  • Snapshot honesty: the JSON is a single moment. A disabled submit button is reported is_disabled: true with a note. The conventions instruct the model to write the interactions that change state, not to assume it stays disabled. In a session, the diff shows the transition itself (is_disabled: false → true), so the test asserts a fact rather than an assumption.
  • Scoped locators (schema 1.4): a nameless or repeated control is located inside its container the way a QA engineer writes it by hand: getByRole('row', { name: 'Charizard' }).getByRole('radio'), getByRole('listitem').filter({ hasText: 'Puthera' }).getByRole('button', { name: 'Pilih Pembeli Ini' }), getByTestId('customer-checkbox-flex').getByRole('checkbox'). The node carries within so an action can target the same element; the expression is verified unique by Playwright like every other locator.
  • Compact wire format (schema 1.3): results are compact JSON and a node field that carries no information is omitted: null fields, frame_path: [], in_shadow: false, kind: "element", empty fallback_locators, and text_content equal to accessible_name. An absent property is null (not applicable, never false); absent structure means the default (main document, light DOM, nothing worth listing). is_visible is always present. Fallbacks appear only when the primary is ambiguous or brittle. Same facts, about a third of the tokens.
  • Assertable state (since schema 1.2): every node reports value (never for password fields), aria_expanded, aria_selected, aria_invalid, described_by (the text of the elements aria-describedby points at, where validation messages live), validation_message (browser constraint validation) and, for <select>, options. Absent state is null, never false.
  • Observed behavior: observed.requests lists xhr/fetch/document requests as method + path + status; static assets are counted in dropped, not listed. observed.navigations lists main-frame URL changes in order. Neither is ever guessed; if a redirect or API path is not in observed, the conventions tell the agent not to wait on it.
  • Diff identity: nodes pair across snapshots by the most stable fact available: test-id, else id, else placeholder, else tag + role + accessible name, plus frame path and a document-order index for non-unique nodes. A hidden menu item that becomes visible is therefore a changed entry, with its primary locator switch (getByText → getByRole) listed as one of the changes. A renamed node with no stable attribute shows as removed + added; the diff says so in its notes.
  • Visibility is Playwright's: is_visible predicts toBeVisible(), so it uses Playwright's rule (not display:none, visibility not hidden, width and height both > 0). Opacity and aria-hidden do not hide an element for Playwright and do not here either.
  • Secrets never leave the server: a value typed into a password field, or a fill marked secret: true, is scrubbed from every string in every result (values, text, console, dialogs, errors) when it is 8+ characters. The set is process-wide and lasts until the server exits, so mark only real secrets: a redacted string blanks that text everywhere, and a locator whose text was redacted is flagged not unique. Password and credential-autocomplete fields never report a value at all.
  • Hidden nodes are included and flagged is_visible: false (tests often assert hidden-ness); pass include_hidden: false to drop them (the count dropped is noted, never silent).
  • Open shadow DOM is traversed and flagged in_shadow — locators pierce it natively, so no >>>/::shadow CSS is ever emitted. Closed shadow roots appear as shadow_boundary marker nodes (detected via pre-navigation attachShadow instrumentation; closed roots created by declarative shadow DOM parse before scripts run and cannot be detected).
  • Same-origin iframes are extracted per-frame with frame_path set (chain frameLocator() in that order). Cross-origin iframes are recorded as opaque cross_origin_frame nodes with URL/name only — their DOM is never touched.
  • Notification & dialog surfaces (role="alert", role="status", dialogs) are extracted like interactive nodes. When a toast library keeps the live region empty and renders the message in a sibling (a common pattern across UI libraries), the message text is pulled from the enclosing container and flagged. For UI that renders late after an interaction, wait_selector_after on extract_semantic_dom_after waits deterministically instead of guessing settle_ms. Since those ARIA roles take names from the author (not contents), their role locator is getByRole('alert') — or with the aria-label name when one exists. For UI that only appears after an interaction (login-success toast), use extract_semantic_dom_after.
  • JS-click cards (product tiles with no anchor/role/test-id) are invisible to the factual rules by design — pass include_click_targets: true to include cursor-pointer boundary elements with content, flagged as heuristic and located by their heading text.
  • Links carry href (schema 1.1) so agents can discover which page to extract next without scraping. Framework-generated ids (rc_select_*, React useId, Radix, MUI...) are detected and demoted to last-resort with a note — they change between builds and must never be primary.
  • viewport: "mobile" (375×812, touch) snapshots responsive states; visibility flags reflect the active media queries.
  • Truncation is loud: max_nodes / depth caps set truncated: true plus a note. Non-unique locators carry is_unique: false and disambiguation guidance.

Development

git clone https://github.com/helmif/semantic-dom-mcp.git && cd semantic-dom-mcp
npm install
npx playwright install chromium
npm run dev        # run the server over stdio via tsx
npm run typecheck  # tsc --noEmit (strict)
npm test           # Vitest suites against real fixture pages in headless Chromium
npm run build      # compile to dist/ (clients can then use "command": "node", "args": ["<path>/dist/index.js"])

Repo layout: src/index.ts (bootstrap) · src/server.ts (MCP surface) · src/browser.ts (Playwright layer + single-shot orchestration) · src/session.ts (persistent sessions) · src/observe.ts (behavior capture) · src/diff.ts (snapshot diff) · src/extractor/ (in-page engine + locator resolution) · src/types.ts (frozen contract, schema 1.3) · src/compact.ts (wire rules) · src/conventions.ts (single source of team conventions).