chrome-mcp

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_new focuses the new tab by default (so "open X" behaves like opening a link, instead of replacing your current page — use tab_new, not navigate, to open without losing the current tab). Pass active: false to 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 frame

Every 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 on

It 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_log sees: the requests page code makes — fetch and XMLHttpRequest, 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 changed

Nodes 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-wall

With 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_text

That 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/list and refused if called — a batch op 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 eval still does nothing without --unsafe-enable-eval.
  • An unknown name fails at startup and prints the catalog, so a typo can never quietly drop click from the surface.
  • chrome-mcp --help prints the full catalog of 39 names to pick from.

On this page