Skip to main content
Glama
semantic-dom-mcp

semantic-dom-mcp

by helmif

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.

Related MCP server: Browser MCP Server

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).

Available Tools

13 tools
check_authA
Read-onlyIdempotent

Diagnostic: navigates with the configured QA_MCP_STORAGE_STATE session and reports whether the page bounced to a login-looking path (session likely expired). Use when extractions unexpectedly return login forms instead of the requested page.

Inputs: url (required), wait_for.

extract_outlineA
Read-onlyIdempotent

The page as a MAP, a few thousand characters: landmark regions (header/nav/main/forms/tables/lists/dialogs) each with a selector you can pass as scope to extract_semantic_dom / session_extract, structured tables (headers, row identity for getByRole('row', { name }), cells) and open dialogs (label/value fields), plus current alert text. Start here on any page you have not seen; then extract one region.

Inputs: url (required), scope, viewport, wait_for, +1 more.

extract_semantic_domA
Read-onlyIdempotent

Navigate to a staging URL and return factual Semantic JSON of all interactive/test-relevant elements with Playwright-native locators and live state. Use this before writing any Playwright test so selectors are real, not guessed.

Inputs: url (required), roles, scope, viewport, +8 more.

extract_semantic_dom_afterA

Like extract_semantic_dom, but first performs a short DECLARED list of actions (fill/click/press/select/goto/wait) in the main frame, then returns Semantic JSON of the RESULTING state. Use it for post-interaction UI a plain snapshot cannot see: success/error toasts, validation messages, opened dialogs. Derive action locators from a prior extract_semantic_dom call. The page must remain on allowlisted hosts after the actions, or nothing is extracted. Uniqueness reflects capture time — accumulating UI (chat threads, lists) can multiply matches later. The result's observed block lists navigations, xhr/fetch requests (method, path, status), console errors, dialogs and popups seen while the actions ran — use them for waitForURL/waitForResponse.

Inputs: url (required), actions (required), roles, scope, +11 more.

get_conventionsA
Read-onlyIdempotent

The team's Playwright test-writing conventions as text (same content as the write_playwright_test prompt), for clients that do not surface MCP prompts.

Inputs: no named parameters.

list_framesA
Read-onlyIdempotent

Diagnostic: navigate to a URL and return its frame tree (frame_path, url, name, same_origin, reachable). Useful for debugging cross-origin iframe boundaries before extraction.

Inputs: url (required), wait_for.

session_actA

Perform a short DECLARED action list (fill/click/press/select/goto/wait, max 20) in an open session's main frame. Returns the page's resulting URL/title and observed: main-frame navigations, xhr/fetch requests (method, path, status — bodies and query strings never captured), console errors, dialogs (auto-dismissed) and popups (recorded, closed). These are the facts for waitForURL/waitForResponse. Derive action locators from a prior extraction (paste its playwright expression). Pass then_extract to get the diff (or a scoped extraction / outline) in the same call. If the actions leave the allowlisted hosts the session is closed and nothing further is extracted.

Inputs: actions (required), session_id (required), settle_ms, then_extract, +1 more.

session_closeA
Idempotent

Close an open session and release its browser context. Always call this when the flow is done. Idempotent: closing an unknown or already-closed id succeeds with was_open: false.

Inputs: session_id (required).

session_extractA
Read-only

Snapshot the CURRENT state of an open session as Semantic JSON (same shape as extract_semantic_dom, plus snapshot_id). Pass diff_against: 'previous' (or a snapshot_id) to receive only what changed: added nodes (new toasts/dialogs/fields), removed nodes, changed properties (value, is_disabled, aria_invalid, described_by…) and the behavior observed in between — far smaller than a full re-extraction and exactly the assertion list for the step. Diff identity: frame + test-id, else id, else placeholder, else tag+role+accessible name (+ document-order index); a renamed node with no stable attribute shows as removed + added; primary_locator.playwright changes say which locator is valid in which state.

Inputs: session_id (required), mode, roles, scope, +7 more.

session_listA
Read-onlyIdempotent

Diagnostic: list open sessions (id, URL, expiry, counts) — recover a session id after losing context.

Inputs: no named parameters.

session_openA

Open a persistent browser session at a staging URL for a MULTI-STEP flow (login → cart → checkout). The page stays open across calls: use session_act to perform declared actions and session_extract to snapshot or diff, then session_close. Fresh context per session (storageState applied if configured). Sessions expire after an idle TTL and are capped in number; the allowlist is re-checked after every step.

Inputs: url (required), viewport, wait_for, wait_selector.

session_verify_locatorsB
Read-onlyIdempotent

verify_locators against an open session's current page (after acting, without a fresh navigation).

Inputs: locators (required), session_id (required).

verify_locatorsA
Read-onlyIdempotent

Count every given Playwright expression against the live page (same engine that verified the extraction). Use after writing a test: paste the spec's getBy*/locator expressions and get matches, uniqueness, and the first matched element per expression, plus a summary. Also the drift check to run in CI against a page.

Inputs: url (required), locators (required), viewport, wait_for, +1 more.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.9/5.0

    Scored across 13 tools

    Disambiguation4/5

    The set has a clear two-tier structure (one-shot extraction/verification vs. persistent session tools), and descriptions carefully separate them. However, extract_semantic_dom_after overlaps conceptually with the session_open/session_act/session_extract flow, and extract_semantic_dom vs session_extract require reading descriptions carefully to pick correctly.

    Naming Consistency4/5

    Almost everything is consistent snake_case verb_noun, and the session_* prefix gives a clean, predictable grouping. Minor deviation: extract_semantic_dom_after uses a temporal suffix rather than a distinct verb, which slightly breaks the pattern.

    Tool Count5/5

    13 tools is well within the sweet spot and each one earns its place, covering extraction, diagnostics, verification, conventions, and full session lifecycle without filler.

    Completeness5/5

    The surface covers the full Playwright test-authoring workflow: page mapping, snapshot extraction, post-interaction observation, locator verification, auth/frame diagnostics, conventions, and end-to-end session management with open/act/extract/verify/close/list. No obvious dead ends.

    Maintenance

    ActivityNo data
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      A
      quality
      D
      maintenance
      Enables AI agents to understand web page structure and content through structured data extraction and element discovery using Playwright, eliminating the need for screenshots.
      4
      14 npm
      MIT
    • A
      license
      A
      quality
      B
      maintenance
      Gives AI agents a compact, semantic interface to the browser, returning structured page snapshots with stable element IDs instead of raw DOM. Enables agents to navigate, interact, and extract information from web pages efficiently.
      26
      33 npm
      19
      MIT