SnapSurf
OfficialSnapSurf is an MCP server for AI-agent web browsing that observes semantic page state, verifies what each action changed, asserts evidence, and manages isolated browser sessions.
Open/navigate pages and get a semantic digest, ranked actionables, navigation/redirect metadata, auth/cookie evidence, challenge/failure details, and observation IDs via
browser_open.Find text across the whole page (
browser_find) and inspect the enclosing card context (browser_parent).Act on pages: click, type, select native options, or press Enter (
browser_act).Verify exactly what changed after every action (
browser_verify) with diff kinds, state/actionability deltas, and a reusablediffId.Assert stored diffs or live page predicates (
browser_assert) with fail-loud checks for transitions, visibility, coverage, URL, existence, and side-effect absence.Save and compare named baselines (
browser_checkpoint,browser_diff).Scroll without acting (
browser_scroll) and read bounded text/href slices of one node (browser_text).Get expanded page views: outline, map actionables, or zoom into a subtree (
browser_page).Open, list, configure, and close isolated sessions (
browser_session_open,browser_session_list,browser_environment,browser_session_close) with independent cookies/storage and viewport/color/motion settings.Escalate to visual inspection with viewport/element screenshots (
browser_screenshot).
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@SnapSurf Open example.com, click the login button, and verify a login form appeared."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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@zumerlabor register the server alone:
claude mcp add --scope user snapsurf -- npx -y -p @zumer/snapsurf@latest snapsurf-mcpCodex has the same plugin (tools plus the browse skill):
codex plugin marketplace add zumerlab/snapsurf
codex plugin add snapsurf@zumerlabCursor, 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: falsemeans 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 serveThen 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 stopFrom 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 chromiumnpm 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:packUsage 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 toolsbrowser_actClick, type, select or press EnterADestructive
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 stateARead-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 baselineARead-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 checkpointBRead-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 QAAIdempotent
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 pageARead-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 digestAIdempotent
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 viewARead-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 cardARead-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 escalationARead-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 actingARead-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 sessionAIdempotent
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 sessionsARead-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 nodeARead-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 changedARead-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
Scored across 16 tools
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.
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.
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.
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
Related MCP Connectors
Control a real browser from AI agents: navigate, click, type, screenshot, read the page.
Give agents eyes on any web page: structured context, and changes explained in plain language.
A real browser for your agent: screenshots, page checks, Markdown and PDFs.
Hosted browser for agents: open pages, search the web, extract data, read PDFs and take screenshots.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables agents to control a real Chromium browser with semantic tools, providing compact observations and outcome-verified actions for web interaction tasks.1MIT
- AlicenseAqualityCmaintenanceEnables AI coding agents to open and exercise web applications in an isolated Chromium browser, capture screenshots and runtime/network diagnostics, and run deterministic page checks with linked evidence.121MIT
- FlicenseNot gradedqualityNot gradedmaintenanceEnables 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-
- FlicenseNot gradedqualityAmaintenanceEnables 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-