Skip to main content
Glama

Snapsurf MCP server

SnapSurf

Web navigation and verification for AI agents.

Most browser tools tell an agent what a page looks like now. SnapSurf tells it what its last action changed: a compact semantic digest of the page, a typed diff after each action (content, state, layout and clickability, including when nothing observable changed), and assertions over that exact diff. Missing evidence and uncertainty are reported, not hidden.

It runs locally as an MCP server or CLI with its own Chromium session. The observation code also works as an in-page library built on SnapDOM.

Version 0.1.3 is experimental.

Add it to your MCP client

Requires Node.js 22 or newer. Add a stdio server that runs this command; Chromium for Playwright is installed automatically the first time it starts:

{
  "command": "npx",
  "args": ["-y", "-p", "@zumer/snapsurf@latest", "snapsurf-mcp"]
}

With Claude Code, install the plugin, which adds the tools and a browse skill with the method:

/plugin marketplace add zumerlab/snapsurf
/plugin install snapsurf@zumerlab

or register the server alone:

claude mcp add --scope user snapsurf -- npx -y -p @zumer/snapsurf@latest snapsurf-mcp

Codex has the same plugin (tools plus the browse skill):

codex plugin marketplace add zumerlab/snapsurf
codex plugin add snapsurf@zumerlab

Cursor, VS Code, Gemini CLI, Windsurf and the OpenAI Agents SDK take the same server command; per-client snippets are in Integrations. Its name in the official MCP Registry is io.github.zumerlab/snapsurf.

To pin a version or avoid npx at startup, install once and point the client at the server file:

mkdir snapsurf && cd snapsurf
npm install @zumer/snapsurf
{
  "command": "node",
  "args": ["/ABS/PATH/snapsurf/node_modules/@zumer/snapsurf/mcp/server.mjs"]
}

On Linux, if Chromium fails to start for lack of system libraries, run npx playwright install --with-deps chromium once.

Ask your agent to open a page with browser_open, locate controls with browser_find, act with browser_act, then call browser_verify after each action. Read structuredContent for the result.

Related MCP server: Render & Verify MCP

If you are an agent

Read AGENTS.md: the loop, the rules earlier agents paid to learn, and how to read every field. skill/SKILL.md is the same method packaged as a Claude Code skill. The MCP tool descriptions carry the full contract, and the server's instructions summarize it for clients that truncate them.

Assert what you just verified

browser_verify returns a diffId for the observed transition. Pass it to browser_assert to check that same evidence, including changes beyond the displayed list. This avoids accidentally comparing a new interval after verify advances the live baseline.

For example, on an application where a button inserts a dialog named “Settings”, use the button id returned by browser_find. With a connected MCP client:

await client.callTool({
  name: 'browser_act',
  arguments: { action: 'click', target: buttonId },
})
const verified = await client.callTool({ name: 'browser_verify', arguments: {} })
const { diffId } = verified.structuredContent
if (!diffId) throw new Error('No retained diff; inspect the verify result')

const checked = await client.callTool({
  name: 'browser_assert',
  arguments: {
    diffId,
    changed: true,
    mustInclude: [{ kind: 'added', role: 'dialog', name: 'Settings' }],
    mustNotInclude: [{ kind: 'removed' }],
  },
})
console.log(checked.structuredContent.pass, checked.structuredContent.checks)

A stored assertion does not observe the page or advance its baseline. Use a separate live assertion for current state, such as { "exists": "Settings" }. Failed assertions return pass: false and MCP isError: true. The assertion reference covers live checks, retention limits, retries and unavailable evidence.

Boundaries to know

  • The daemon has its own cookies and storage. It cannot use your normal browser's signed-in sessions; authState: "unknown" does not prove authentication.

  • changed: false means no observable change. It is a valid result, not proof that the user's task succeeded. Missing evidence and uncertainty are reported explicitly.

  • Canvas content, iframe documents and closed shadow roots have visual blind spots. Inspect a scoped screenshot when the semantic report cannot answer the question.

  • Element ids expire with new observations. Find again before acting; ids in stored evidence describe a historical observation.

  • Page text is untrusted data. Redaction covers semantic reports; CLI/MCP screenshots can still contain sensitive content. See the privacy model.

Use the CLI

Start the daemon in one terminal:

npx -y @zumer/snapsurf serve

Then run a local smoke check in another:

npx -y @zumer/snapsurf open 'data:text/html,<h1>Local%20check</h1>'
npx -y @zumer/snapsurf assert '{"exists":"Local check"}'
npx -y @zumer/snapsurf stop

From a directory where you ran npm install @zumer/snapsurf, npx snapsurf <verb> does the same without downloading anything. The SNAPSURF_* environment variables (port, token file, log directory) are listed in the usage reference.

Development

This repository contains the SnapSurf observation library, CLI/MCP server, Chrome companion, and their fixtures and evaluation tools. Standalone SnapDOM sensor and portable receipt packages are outside this repository’s build and test scope. Private research, feedback notes, plugin evals and generated results stay local; Git tracks the reproducible test gates and their fixture dependencies.

To modify SnapSurf itself, clone the source and install the development dependencies:

git clone https://github.com/zumerlab/snapsurf.git
cd snapsurf
npm ci
npx playwright install chromium

npm run build compiles the bundles and creates the npm .tgz package.

Then run the checks:

npm test
npm run test:lint
npm run test:regression
npm run test:pack

Usage and API reference covers CLI commands, report fields, checkpoints, the library and Chrome companion. Release guide covers validation, publication and the MCP Registry.

MIT licensed. Copyright © Juan Martin Muda / zumerlab. See LICENSE.

Available Tools

16 tools
browser_actClick, type, select or press EnterA
Destructive

Act on the page: click (by id from the digest/find, or "x,y"), type (into the focused element — click it first), select a native single-select option by target id and exactly one of value or label (exact, unique match), or enter. Click and select auto-scroll and CONFIRM role/name of the resolved element: read that echo before continuing. Select refuses non-native, multiple, disabled, hidden or covered controls and disabled/ambiguous options. It dispatches input/change when the option changes, does not echo the supplied choice, omits choice arguments from audit logs, and preserves the observation baseline. Privacy rules and readonly policy apply. After EVERY action, call browser_verify, even when select reports selectionChanged:false.

Inputs: action (required), text, label, value, +2 more.

browser_assertAssert a transition or page stateA
Read-only

Assert the EXACT transition already read from browser_verify by passing its diffId: immutable, full diff evidence; no new observation and no baseline or id-epoch advance. Stored results return diffId, beforeObservationId, afterObservationId, observationId (the historical after observation), baselineAdvanced: false and evidenceSource: stored. Historical evidence survives later navigation; its element ids are historical, not actionable — find again before acting. Stored diffs support ONLY changed, mustInclude, mustNotInclude, only, maxChanges, becameVisible and becameCovered. Mixing diffId with exists, notCovered, url/urlIncludes, ignore, settleMs, retry or keepBaseline (even false) is pass:false; use a separate live assertion for current page predicates. Unknown, expired, evicted, foreign-session or privacy-invalidated ids return pass:false with error {code: DIFF_UNAVAILABLE, message}, never a live fallback. Without diffId, the existing LIVE assertion mode checks the diff since the last observation and consumes its baseline at the END unless keepBaseline:true; calling it after verify therefore checks a NEW interval. Checks any combination of: url (substring of the current URL), changed (expect the diff since the last observation to be true/false — the faithful negative makes "my action did nothing" ASSERTABLE), mustInclude ([{kind, role, name}] entries that must appear in the diff; kind ∈ added/removed/content/state/style/moved/resized — a framework re-render that REPLACES a node reports kind possible-replacement, and added/removed matchers accept it with STRICT side reading: an added matcher matches the after-side name/role, a removed matcher matches ONLY the before-side name/role (never the after side; selector specs never match through the alias), and the check result says "found (via possible-replacement — identity ambiguous)" instead of a plain green), exists (text findable anywhere on the page), notCovered (text whose best match must not be occluded). FAIL-LOUD CONTRACT: unknown spec keys, empty specs and missing baselines are hard pass:false with a reason — confusion never looks green. Returns structured {pass, hasBaseline, attempts, checks[], changes[]}; the diff evidence (with state from/to) travels with every result. Also: mustNotInclude (assert side-effect ABSENCE), maxChanges, becameVisible/becameCovered (actionability deltas), mustInclude entries accept selector and to:{state:value} (directional state — assert the menu IS open), settleMs and retry:{budgetMs} re-walk against the SAME baseline until pass or budget (CSS transitions land mid-flight). exists searches accessible names AND page text. SPA soft navs: results with a baseline include navigated:true + baselineUrl when the URL moved since the baseline was taken — that diff spans two pages of one document; re-observe on settled content (non-zero, stable actionables) before trusting change-based checks.

Inputs: url, only, retry, diffId, +12 more.

browser_checkpointSave a named baselineA
Read-onlyIdempotent

Save the currently observed state as a NAMED baseline (before a risky action). NOT undo: a comparison point for browser_diff.

Inputs: name (required), sessionId.

browser_diffDiff against a checkpointB
Read-only

Diff the current state against a checkpoint saved with browser_checkpoint: everything that changed since that known point. Note: the next browser_verify baseline becomes the current state.

Inputs: name (required), sessionId.

browser_environmentConfigure this session for visual QAA
Idempotent

Get or update this session’s viewport, colorScheme and reducedMotion for responsive, light/dark and motion QA. Omitted settings are preserved; with no settings this only reads the current environment. Returns environment. Updates apply to every page in this session and future popups, preserving cookies, storage, privacy rules and other sessions. The observation baseline remains unchanged (baselineAdvanced:false); call browser_verify after every update to inspect resulting page changes. Changing settings is refused under readonly policy because resize/media handlers may trigger page actions.

Inputs: viewport, sessionId, colorScheme, reducedMotion.

browser_findFind text on the pageA
Read-onlyIdempotent

Search text across the WHOLE page (not just the visible part) and get RANKED matches in structuredContent: id, role, name, text (same string, honest label), href (mailto:/tel: pass through intact) and truncated when a value was cut. Whole words/phrases rank before incidental substrings. Embedded data hrefs use the same resource/recovery descriptor as browser_open. Optional contextChars allocates a TOTAL text budget to specific relevant sections before broad ancestors, avoiding overlapping context: each included match gets context centered near the first query occurrence with text, totalChars, observationId, offset and prefixOmittedChars (leading text skipped). truncated and a browser_text continuation describe remaining text after the window; read offset 0 for the beginning. Context uses the same frozen first-read text as browser_text; contextMatchesOmitted explicitly counts matches without context; contextOmittedReason and contextCoveredBy explain overlap omissions. The right tool to locate something specific on long pages — do not ask for the full outline.

Inputs: text (required), sessionId, contextChars.

browser_openOpen page and read digestA
Idempotent

Navigate to a URL and get the semantic DIGEST (~2-3KB): landmark regions with ids, headings with their section, and the top-15 RANKED actionables with complete absolute navigation hrefs in structuredContent (privacy rules still apply). Embedded data URLs instead return a resource descriptor with MIME type, encoded length, explicit omission and bounded browser_text recovery arguments; no opaque payload is silently truncated. PDF links include a document hint with URL, title, evidence and source observation/id for an external PDF reader. A successful application/pdf response returns document {type, mediaType, url, title, source?, reader, textExtracted: false}, instead of pretending its text was observed; source retains the prior observation/id/title/href when an exact current link supplied the destination. Navigation metadata includes requestedUrl, finalUrl, navigationUrlsSanitized: true (navigation query values and opaque payloads remain hidden), and redirectChainAvailable. When available, redirectChain lists observed HTTP response URLs/statuses, with redirectChainScope: http, redirectChainTotal and redirectChainTruncated; client-side navigations are reflected by finalUrl, never invented as HTTP redirects. Ids (n_xxx) expire on every new observation. A top entry with placeholder: true is an EMPTY form field whose name is its placeholder — a prompt, never data from the site. Every observation reports authState and cookiesForOrigin: this tool drives ITS OWN isolated per-session BrowserContext, cookie jar and storage. authState is conservatively unknown; a cookie count is evidence, not proof of identity, because authentication can also live in storage, bearer state or the URL. For a task that needs the real signed-in session from another browser, this is the wrong instrument. If the site answered with a bot-mitigation interstitial, structuredContent carries blocked: true and challenge {vendor, reason, status, signal, and vendors when more than one is detected — vendors chain, and a confidently wrong name is worse than unknown for per-vendor retry routing}: the content was WITHHELD, which is a different answer from a page that has little on it — fall back to another fetcher rather than recording an empty result. A request that never reached an HTTP response returns failure {layer: dns|tls|transport|http, code, hostUp} with ok:false and navigationCompleted:false. Without an HTTP response, finalUrl is null and retainedUrl identifies the previous page separately; a DNS or certificate failure is neither a block nor an empty page. The open waits (bounded) for window.onload AND briefly watches the fresh document for timer-delayed first paints (entry ads armed via setTimeout at parse time), so late overlays/modals enter the FIRST digest; if the document is STILL not complete, structuredContent carries loading {readyState, waitedMs} and the prose says so — treat the digest as a truthful walk of an UNFINISHED page and re-observe before trusting completeness. Returns runtime (version/build) for reproducible feedback and observationId (opaque identity of this observation) in structuredContent, and digest (marks/heads/top) in structuredContent and prose — read the fields, do not parse the text. When observed images exist, digest.visualContent declares that their pixels have not been read as text, with bounded example IDs; a negative text search is not evidence that content inside those images is absent. Use browser_screenshot when those pixels matter. After a SAME-ORIGIN navigation, structuredContent may also carry carried: which strong-identity elements (data-testid / authored accessible names) persisted from the previous page and how their state/content moved (the cart badge "1"→"2"), including contentChanges that identify changed semantic components without exposing raw values, plus only-before/only-after COUNTS of page-specific content — those counts are "different page", never removals/additions. Optional redact: session privacy rules — any name/label/text/state string containing a listed term leaves every observation as [redacted], and each observation carries an attestation that the policy ran (policyRevision, rulesActive) — never hit counts, which would tell you whether and how often the hidden term occurs. Raw form values are never returned; sensitive categories use coarse change signals and declare same-bucket uncertainty.

Inputs: url (required), digest, redact, sessionId, +1 more.

browser_pageOutline, map or zoom viewA
Read-onlyIdempotent

Expanded views when the digest is not enough: outline (full structure trimmed to 12KB), map with offset (pages actionables beyond the top), or zoom with id (observes ONLY that subtree — the detail of a region/card; renews ids, global baseline untouched). Explicit escalation — digest first. Returns outline in structuredContent for view:"outline", with truncated when trimmed. Map returns structured map entries, offset, returned, total, nextOffset and observationId; hrefs and embedded-resource descriptors follow browser_open.

Inputs: view (required), id, offset, sessionId.

browser_parentObserve the enclosing cardA
Read-onlyIdempotent

Climb from a find/digest match to the CARD around it (the nearest container with ≥2 actionables) and observe just that subtree: the way from "found the price text" to "here is the clickable title next to it". Returns the card with fresh ids; the global look baseline stays untouched. When the card carries no prose beyond its actionables and the page splits the logical card across sibling rows (HN-style tables), structuredContent also carries siblingRowText — the metadata row BESIDE the card, as declared text, never merged into the card ids. The right follow-up when browser_find located an inner node and you need its actionable context — never infer the card by id arithmetic.

Inputs: id (required), sessionId.

browser_screenshotScreenshot as escalationA
Read-onlyIdempotent

Pixels as ESCALATION, not default: SnapDOM render of the viewport, optionally centering the element identified by id. capture identifies the renderer and pinned engine version, commit and SHA-256. Its coverage reports known unreadable iframe regions intersecting the viewport in the top document and open shadow roots, with status partial when any are found; no-known-omissions is only this iframe check, not a guarantee of complete visual fidelity. Blank areas or placeholders do not mean those frames are empty. Browser sandbox/access restrictions remain unchanged; native browser screenshots are separate diagnostic evidence. Only when the doubt is genuinely visual (layout, color, overlap) — for what changed there is browser_verify.

Inputs: id, sessionId.

browser_scrollScroll without actingA
Read-onlyIdempotent

Scroll WITHOUT acting: by element id (to center), to "top"/"bottom", or to an absolute y in pixels. The one legitimate reason: dense listings hydrate their content lazily on scroll and the semantic walk honestly sees only the DOM that exists — scroll, then browser_verify to see what appeared. Ids from the current observation remain valid (scrolling does not re-observe). Includes a bounded settle for the lazy loaders.

Inputs: target (required), sessionId.

browser_session_closeClose a sessionA
Idempotent

Close a session opened with browser_session_open and free its complete context, including every popup it created. Sessions also close themselves after 10 minutes idle, so a crashed run does not leak pages.

Inputs: sessionId (required).

browser_session_listList live sessionsA
Read-onlyIdempotent

List the live sessions with their current URL, observation number and idle time.

Inputs: no named parameters.

browser_session_openOpen an isolated sessionA

Open an independent browsing session and get its sessionId and environment, with epoch 0, URL about:blank and runtime version/build belonging to that new session. Optional viewport, colorScheme and reducedMotion configure responsive/media QA before navigation; defaults are 1280×800, light, no-preference. Each session owns a private BrowserContext, cookie/storage jar, popup tree, observation counter and ids, so several sweeps run AT THE SAME TIME without invalidating or authenticating each other. Pass the returned sessionId on every call belonging to that sweep. Close it with browser_session_close when done.

Inputs: viewport, colorScheme, reducedMotion.

browser_textRead the text of one nodeA
Read-onlyIdempotent

Read visible text of ONE node or section by id, or its href when source:href is explicit, with a bounded character budget (maxChars defaults to 600, maximum 12000). Returns text, textSource, capturedAt (first-read timestamp), totalChars, returnedChars, offset, nextOffset, truncated, observationId and continuation in structuredContent. Pass a non-null continuation directly to browser_text for the next slice; it includes sessionId. offset > 0 requires observationId from the initial read. The first read freezes redacted text at capturedAt (not at observation time) so later slices cannot splice a changing document; re-observe and re-find to refresh it. Href recovery also uses frozen bounded slices; embedded data payload recovery is refused while privacy rules are active. Snapshot storage is bounded per observation (1000000 UTF-16 units / 128 nodes); exceeding it returns an explicit error. Continuations fail on expired observations, detached ids, navigation or privacy-policy changes. Offsets count JavaScript UTF-16 code units. A cut value must be continued, not recorded as complete; the prose also marks truncation.

Inputs: id (required), offset, source, maxChars, +2 more.

browser_verifyVerify what changedA
Read-only

WHAT CHANGED since the last observation — the verification of your action. Returns changed (a faithful negative: if your click did nothing it says so instead of letting you believe you acted), the list of changes with kind (added/removed/state/style/moved) role and name, and what became covered or visible. Possible replacements also carry beforeName, so the prior and current identities are both explicit. Call it after EVERY action instead of comparing screenshots, then pass its diffId to browser_assert to check THIS exact transition. When a baseline exists, a full observation returns diffId, beforeObservationId, afterObservationId, observationId (the after observation), and baselineAdvanced: true. A stored diff covers the FULL evidence, including changes beyond the presentation cap and folded wrappers. No baseline means no diffId. Retention is bounded per session (32 records, 8 MiB total, 10 minutes); an individually oversized record returns diffAvailable: false and diffError: {code: DIFF_TOO_LARGE, message}, without a diffId. structuredContent carries changed, changes (list of {kind, role, name, beforeName?, id, from?, to?}), changesTotal, changesShown, changesOmitted, and changesOmittedByKind — read those rather than parsing the prose. State changes include their before/after state in from/to. Reading aids: the capped changes summary prioritizes state, content, and actionability-related changes before other semantic changes and geometry; omissions, including folded wrappers, are explicitly counted by kind. It omits folded wrapper nodes of an ADDED subtree (identity-free generic wrappers only — authored names never fold; foldedWrappers counts them and changesTotal is the full diff count), and geometryOnly: true flags a diff that is ONLY moved/resized AND changed no actionability — a scope reflow (scrollbar, container resize) you can skim past. After a same-origin navigation, carried reports the strong-identity elements that persisted across pages and their state/content transitions (see browser_open).

Inputs: sessionId.

Tool Schema Changelog

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

Loading tool schema changelog…

    TDQS

    A4.1/5.0

    Scored across 16 tools

    Disambiguation5/5

    Each tool targets a clearly distinct operation: opening vs. finding vs. acting vs. verifying vs. asserting vs. scrolling vs. reading vs. screenshotting. Descriptions consistently cross-reference the correct tool for each edge case (e.g., 'use browser_screenshot when those pixels matter', 'call browser_verify after EVERY action'), making misselection very unlikely.

    Naming Consistency5/5

    Every tool follows a strict browser_<noun_or_verb> snake_case convention: browser_open, browser_find, browser_act, browser_verify, browser_assert, browser_scroll, browser_screenshot, browser_session_open, etc. No mixing of styles or inconsistent prefixes.

    Tool Count4/5

    16 tools is slightly heavy but each earns its place within a sophisticated browser-automation surface (session lifecycle, observation, action, verification, assertion, media). No obvious redundancy; the count is justified by the depth of the domain.

    Completeness4/5

    Strong lifecycle coverage: sessions open/close/list, navigation, finding, acting, verification, diff/checkpoint, screenshots, text extraction, environment control. Minor gaps: no dedicated file-upload, download handling, or explicit keyboard-modifier/scroll-wheel action, but these can be worked around or are outside core scope.

    Maintenance

    ActivityNo data
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables agents to control a real Chromium browser with semantic tools, providing compact observations and outcome-verified actions for web interaction tasks.
      1
      MIT
    • F
      license
      Not graded
      quality
      Not graded
      maintenance
      Enables AI agents to browse and interact with web pages through a persistent headless Chromium session, serving compact structured orientations, targeted element queries, and full detail only when explicitly requested. It supports navigation, observation, form filling, monitoring, and session management while cutting token overhead by up to ~140x versus raw accessibility-tree dumps.
      4 npm
      -
    • F
      license
      Not graded
      quality
      A
      maintenance
      Enables AI agents to browse and interact with web pages through a typed, structured representation — receiving a compact orientation on arrival, targeted queries for specific elements, and full detail only when explicitly requested, instead of a bulky accessibility-tree dump. Supports navigation, clicking, typing, form filling, screenshots, tabs, console/network monitoring, and selectable tool profiles to cut per-call definition overhead.
      4 npm
      -