Guides
Multi-tab batches, iframes and shadow roots, observers, snapshot diffs, role locators, auth walls and PDF printing.
The tools cover tabs, navigation, interaction (click/type/press/hover/
scroll/select_option), reads (get_text/get_html/screenshot/eval/wait_for),
an accessibility snapshot (interactive elements with stable refs the model can
target instead of guessing CSS selectors), session access (get_cookies/storage),
helpers (extract_links/read_as_markdown/fill_form/download_file/upload_file),
and chrome_status. upload_file sets local file(s) on a file <input> without the
OS dialog (requires --enable-uploads).
click/type accept trusted: true for real OS-level input (works on
React/Vue controlled inputs); interactions auto-wait for the target to appear.
Driving several tabs at once — batch
batch runs many tool calls in one request — parallel (default) or
serial (with optional stopOnError). Each sub-op goes through the same
policy gate, rate limit, and error handling as a direct call (no bypass,
no nesting). Use it to fan work out across tabs:
// open three product pages (background, so they don't fight for focus)…
{ "name": "batch", "arguments": { "ops": [
{ "tool": "tab_new", "args": { "url": "https://a.example/p" } },
{ "tool": "tab_new", "args": { "url": "https://b.example/p" } },
{ "tool": "tab_new", "args": { "url": "https://c.example/p" } }
]}}
// …then read them all at once (wall-clock ≈ the slowest one, not the sum)
{ "name": "batch", "arguments": { "ops": [
{ "tool": "get_text", "args": { "tabId": "<a tabId>" } },
{ "tool": "get_text", "args": { "tabId": "<b tabId>" } },
{ "tool": "get_text", "args": { "tabId": "<c tabId>" } }
]}}In parallel mode, tab-scoped ops must pass an explicit tabId — the
active-tab default is unsafe under concurrency, so it's rejected rather than
silently mis-routed. (tab_new, tabs_list, chrome_status are exempt.)
tab_newfocuses the new tab by default (so "open X" behaves like opening a link, instead of replacing your current page — usetab_new, notnavigate, to open without losing the current tab). Passactive: falseto open in the background; parallel batches do this automatically.
Reaching into iframes and shadow roots
A selector that "should" match but doesn't almost always means the element is
somewhere your selector cannot reach: inside an <iframe> (checkout widgets,
OAuth consent screens, embedded editors) or inside a web component's shadow root.
Shadow roots are handled for you — every selector and every ref now resolves
through open shadow roots, so anything snapshot shows you is something you can
click. (It used to show you elements no click could reach: the snapshot walked
shadow roots, the actions did not.)
Frames are opt-in, because reaching into one is a decision:
frames_list {} // what frames exist, and their URLs
click { "selector": "#pay", "allFrames": true } // find it in whichever frame has it
get_text { "frameId": 7 } // pin one frameEvery frame is authorized against its own URL before anything runs in it, so an allowlisted page embedding a third-party iframe does not become a way to read that third party. Frames whose origin isn't on your allowlist are skipped.
Seeing why a page broke — console_logs, network_log, dialogs
Reading the DOM tells you what a page looks like after it failed, not why. With
--enable-observers, an in-page hook records console output, uncaught errors,
and fetch/XMLHttpRequest traffic, and intercepts native dialogs:
console_logs { "level": "error" } // the exception the page swallowed
network_log { "failedOnly": true } // the 500 behind the blank screen
dialogs { "policy": "accept" } // answer confirm() with true from here onIt is off by default and deliberately so: the hook patches console,
fetch, XMLHttpRequest and the dialog functions on every allowlisted page in
your real browser. When it's on, it is registered only for the domains on your
allowlist, at document_start (so it catches load-time failures), and nothing it
records leaves the page until a tool call reads it — through the same gate as any
other page read.
Dialog interception is also a fix, not just an observation: alert/confirm/
beforeunload block the renderer, so a click that opened one used to hang every
injected script until the command timed out and reported TIMEOUT with nothing
to point at. With observers on, the dialog is answered (dismiss by default:
confirm → false, prompt → null) and recorded.
What
network_logsees: the requests page code makes —fetchandXMLHttpRequest, with method, URL, status and duration — plus Resource Timing entries (scripts, images, styles) when you ask for them. Not the document request, redirects, or headers. That is the cost of not holding a debugger session open on your browser.
Only what changed — snapshot { diff: true }
A snapshot is the most expensive read in the tool surface, and the loop that uses it most (snapshot → click → snapshot) re-sends a page that is mostly identical every time. Ask for the delta instead:
snapshot { "diff": true } // added / removed / changed only
click { "selector": "#save", "snapshotAfter": true } // what the click changedNodes are matched across snapshots by role + accessible name, not by ref —
refs renumber in document order on every snapshot, so diffing on them would
report an unchanged button as removed-and-re-added the moment anything above it
appears.
Targeting by role and name
Actions accept a locator instead of a CSS selector, so you don't need a snapshot first just to learn a ref:
click { "role": "button", "name": "Sign in" }
type { "role": "textbox", "name": "Email", "text": "a@b.com" }Resolution is server-side and refuses to guess: an ambiguous locator fails with
the candidates listed rather than acting on the first one (pass nth to pick).
Did the session expire? — auth_check and failOnAuthWall
Reusing a signed-in Chrome removes the login step, but a session cookie can
still expire mid-run. Without a distinct signal the next step fails as
SELECTOR_NOT_FOUND or TIMEOUT, and an eval harness scores the run as an
agent failure when it was an auth failure. Every snapshot now carries an
authWall verdict when the page looks like a sign-in wall, and there is a
dedicated probe:
auth_check {} // { authRequired, confidence, signals }
auth_check { "failOnAuthWall": true } // [AUTH_REQUIRED] error instead
navigate { "url": "https://app.example.com/dashboard", "failOnAuthWall": true }
snapshot { "failOnAuthWall": true }For a harness, set it once instead of per call:
npx -y @mehmoodqureshi/chrome-mcp --allow-domain app.example.com --enable-mutations --fail-on-auth-wallWith the flag on, every step that can move the tab (navigate, click, type,
select_option, press, fill_form, back, forward, reload) checks the
page it landed on and fails with [AUTH_REQUIRED] if that page is a sign-in
wall, and a wait_for that times out on such a page reports [AUTH_REQUIRED]
instead of [TIMEOUT]. Each guarded step costs one extra snapshot round-trip;
with the flag off the cost is zero. [AUTH_REQUIRED] is where a harness pauses
for a human to sign in again in the same Chrome, then retries the step. chrome-mcp
never re-authenticates on its own: it holds no credentials, by design.
Detection reads only what the snapshot already has: the URL (sign-in routes,
identity-provider hosts such as accounts.google.com, login.microsoftonline.com,
Okta, Auth0), the title, password fields, and sign-in controls. high
confidence needs two independent cues (a password field plus a sign-in button,
say); a lone password field or a bare /auth/... URL is medium. A header
"Sign in" link on an ordinary page never counts. failOnAuthWall fires only on
high, so a harness can bucket [AUTH_REQUIRED] separately from every other
failure while a settings page with a "current password" field carries on.
Printing — print_pdf
print_pdf { "landscape": true }Renders through Chrome's own print pipeline and saves to the task's results/
dir, returning the path and size. The bytes themselves are never returned — a
PDF is megabytes of base64 no model can read.
Paying less per turn — --tools
Every MCP server you connect costs context before you ask it anything: the host sends the whole tool catalog to the model on every turn. chrome-mcp's 39 tools are 27 KB of JSON Schema, about 6.9k tokens, on each one.
Most runs need a handful of them. --tools advertises only those:
npx -y @mehmoodqureshi/chrome-mcp \
--allow-domain app.example.com --enable-mutations \
--tools tabs_list,tab_new,navigate,snapshot,click,type,get_textThat surface is 6.0 KB, ~1.5k tokens — an 82% cut against the full catalog, for a run that was never going to print a PDF or upload a file.
- Comma-separated and repeatable:
--tools navigate,get_text --tools click. - A tool left out is hidden from
tools/listand refused if called — abatchop naming it fails the same way an unknown tool does. Hiding a tool is a real restriction, not a display filter. It is not a substitute for the policy gate, though:--tools evalstill does nothing without--unsafe-enable-eval. - An unknown name fails at startup and prints the catalog, so a typo can never
quietly drop
clickfrom the surface. chrome-mcp --helpprints the full catalog of 39 names to pick from.