# nodriver — undetected browser automation [Health: Active]

**Category:** 📂 Browser Automation  
**Repository:** https://github.com/andresolbach/nodriver-mcp-server  
**GitHub Stars:** 7  
**Views:** 8  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/nodriver-undetected-browser-automation

## Description
Undetected, anti-bot-resistant Chrome automation. 57 tools. Stealth chrome-devtools-mcp alternative.

## Tools
Capabilities this server exposes over MCP:

- **list_frames** — List the page's frames, so their content can be reached at all.

Chrome's accessibility tree stops at a frame boundary, and
document.querySelector never crosses one, so anything inside an iframe —
payment fields, consent walls, embedded editors, CAPTCHA widgets — is
invisible to take_snapshot and query_selector by default.

Index 0 is always the main frame. Pass an index or frame id as the `frame`
argument of get_page_content, query_selector, evaluate_script,
wait_for_selector or take_snapshot to work inside one.
- **bypass_insecure_warning** — Click through Chrome's "Your connection is not private" interstitial.

Use this when a navigation lands on an SSL/certificate warning page (expired
or self-signed certificate, hostname mismatch) instead of the site itself —
the snapshot then shows a warning page rather than the expected content.
This performs the Advanced -> Proceed click for you.

Has no effect on any other kind of page.
- **cf_verify** — Attempt to solve a Cloudflare "Verify you are human" challenge.

Use when a page is stuck on a Cloudflare interstitial — a checkbox widget,
or "Checking your browser before accessing". Drives nodriver's built-in
verification bypass, which locates the checkbox visually and clicks it.

Needs opencv, which is an optional extra: install nodriver-mcp[cf], or this
reports that it is missing rather than failing obscurely. Many challenges
clear by themselves after a few seconds, so wait_for(["some text from the
real page"]) is worth trying first — and this says so when it looks at the
page and finds no challenge at all.
- **click** — Click an element addressed by its snapshot uid.

This is the click you want in almost all cases — prefer it over click_at,
because a uid survives layout shifts and raw coordinates do not. The element
is scrolled into view first, so it need not be visible beforehand.

Sends real CDP input events, so the page sees `isTrusted=true`, which is the
entire point of an undetected driver. Because those are delivered by
coordinate, the click is aimed at a point that actually hits the element:
several points inside it are hit-tested first, since a sticky header or a
banner can cover the centre. If none of them reach it, `if_covered` decides
what happens, and the response always says which path was taken.

Two other situations force the scripted fallback regardless: a touch-emulated
target, where CDP mouse input can crash the renderer, and a CDP click that
times out or errors. Every step is bounded at 10s, so a wedged page cannot
hang the call.

On "unknown uid", take a fresh take_snapshot and retry with the new uid.
- **click_at** — Click a raw viewport coordinate instead of an element.

Use `click` with a uid whenever the target shows up in a snapshot — it is
robust against layout shifts, this is not. Coordinates are for surfaces with
no addressable element: canvases, maps, video players, image hotspots.

The point must lie inside the current viewport; scroll_page or
scroll_to_selector first if it does not. Coordinates are in CSS pixels and
ignore the device pixel ratio, so they match what emulate/resize_page report.

Same input path as `click`: real CDP events, the same reported scripted
fallback, the same 10s bound per step.
- **close_page** — Close a single browser tab.

The last remaining tab cannot be closed — use close_browser to shut Chrome
down entirely. Closing the selected page clears the selection, so subsequent
tools fall back to the most recently opened tab.

Returns the remaining open pages, because indices shift when a tab closes:
re-read them from this response rather than reusing older ones.
- **close_browser** — Quit Chrome entirely, closing every tab.

Unlike close_page (which always keeps one tab alive), this tears down the
whole browser. Chrome relaunches automatically on the next tool call with
the currently selected profile, so this is also how you apply pending launch
flags without switching profiles.

On an ephemeral temp profile (the default) this discards cookies, logins and
localStorage — save_session first if you need them back. Persistent profiles
created with create_profile keep everything.

It keeps the browser itself. The name, its selected profile and its launch
flags survive for the next relaunch, so the name goes on counting against the
12-browser limit. When an agent is finished for good, call shutdown_browser
instead: it also releases the name.
- **drag** — Drag one element onto another (press, move, release).

Both uids must come from the same take_snapshot. Both ends are scrolled into
view and hit-tested first, and the pointer is moved in steps with the button
held, which is what mouse-driven sortables and sliders listen for.

This drives the mouse. Native HTML5 drag-and-drop — the dataTransfer kind —
is a separate protocol that a synthetic mouse does not trigger in Chrome; if
nothing happens on a page that uses it, that is why.
- **emulate** — Emulate network, CPU, geolocation, user agent, color scheme or viewport.

Applies to the selected page and persists across navigations until
reset_emulation. Every parameter is independent — pass only what you want to
change, leave the rest at their defaults. Settings already in force are
carried over rather than dropped: a user_agent on its own keeps the client
hints a device preset established, and a viewport on its own keeps its touch
and mobile flags unless you clear them with `notouch` / `nomobile`.

To emulate a real phone or tablet, use emulate_device instead: it sets user
agent, client hints, viewport, DPR and touch as one coherent set, which
hand-assembled overrides here get wrong in ways anti-bot systems detect.

Turning `touch` on in the viewport also changes how `click` behaves — CDP
mouse input can crash a touch-emulated renderer, so clicks fall back to the
scripted path (`isTrusted=false`) for as long as touch is enabled.
- **reset_emulation** — Clear every emulation override on the selected page.

Resets in one call: network throttling, CPU throttling, geolocation, user
agent and client hints, color scheme, viewport, device pixel ratio, page
scale and touch emulation — back to the real browser defaults.

Use after emulate or emulate_device to make the page behave like ordinary
desktop Chrome again. Turning touch emulation off here also restores trusted
CDP clicks.
- **emulate_device** — Emulate a phone or tablet with one internally consistent set of signals.

Preferred over assembling `emulate` parameters by hand for mobile work: user
agent, UA client hints (Sec-CH-UA-*), viewport, device pixel ratio, touch
support and Accept-Language are set together so they cannot contradict each
other — a mismatch between them is a classic automation tell.

Presets are pixel_7, pixel_7_landscape and ipad_air (aliases are listed in
the `device` parameter). ipad_air deliberately reports desktop-class Safari
with touch and sends no client hints, which is what a real iPad does.

Applies to the selected page and survives navigation. To have mobile signals
present on a page's very first request, pass `device` to new_page or
navigate_page instead of calling this afterwards. Undo with reset_emulation.
- **evaluate_script** — Run JavaScript inside the page and get the result back as JSON.

The escape hatch for anything the other tools do not cover: reading computed
styles, calling a page's own JS API, or extracting structured data in a
single round trip instead of a dozen snapshot-and-click cycles.

Without `args` the function runs at page level in the main world. With
`args`, the given uids become real element references, which is how you
operate on one specific element from a snapshot.

Return values must be JSON-serialisable — DOM nodes, functions and circular
structures are not, so map them to plain values inside the function. A script
that throws comes back as a failed call carrying the JavaScript error, not as
a result you have to inspect for the word "Error".

Pass script_path to run a function kept in a .js file, and file_path to write
the result to disk instead of into the conversation.
- **fill** — Set the value of an input, textarea, select or contenteditable element.

Selects whatever is already in the field and types over it, character by
character, so `input` events fire and React/Vue-style controlled components
register the change. It deliberately does not blank the field first: that
makes such components re-render, the focus follows the replaced node, and
the keystrokes end up nowhere. For <select>, the option is chosen by value
and a `change` event is dispatched.

The value is read back afterwards. If it did not land you get an error, not
a success you cannot trust — a field can be read-only, disabled, covered by
an overlay, or rewrite what you type, and silently reporting success would
send you looking for the problem several steps later.

Use type_text instead when you want to append to a focused field rather than
replace its contents. For several fields at once, fill_form does it in one
round trip.
- **fill_form** — Fill several form fields in a single call.

Behaves like `fill` per field, but costs one round trip instead of one per
field. Fields are processed in the order given, which matters on pages that
reveal or enable later fields in response to earlier ones.

A field that fails does not abort the rest: the response reports success or
the specific error per uid, so a partially filled form is always visible
rather than silent.
- **set_checked** — Tick or untick a checkbox, or select a radio button, and verify it took.

Use this instead of fill for anything with a checked state. fill types over a
field's text, which a checkbox does not have — and its read-back compares
`value`, which a checkbox never changes, so a fill could report success while
nothing was selected.

Idempotent: asking for the state it is already in is reported and does not
click. Otherwise it delivers a real CDP click, on the element or, when the
input is visually hidden behind a styled label (the usual custom-control
pattern), on the label that controls it. The state is read back afterwards, so
a failure is an error rather than a success you cannot trust.
- **select_option** — Choose an option in a <select>, by value, visible label or index.

fill also works on a <select>, but only matches the value attribute, which is
not what a snapshot shows you — so it had to be guessed. This matches what you
can actually see, and when nothing matches it lists the real options instead of
only saying no.
- **get_console_message** — Read one console message in full, by id.

list_console_messages truncates every entry to 200 characters; use this to
get a complete stack trace or error payload.

Requires enable_console_collection to have been called for this page. The
server leaves the CDP Runtime domain disabled by default because enabling it
is itself something sites can detect.
- **get_cookies** — List browser cookies with their domain, path and secure flag.

Reads the whole browser cookie jar, not just the current page, unless you
pass `url`. Values are returned in full, so treat the output as sensitive —
it contains live session tokens.

To carry these across a browser restart use save_session, or switch to a
persistent profile with use_profile.
- **get_local_storage** — Read all localStorage entries for the current page's origin.

Values are truncated to 200 characters each; for one entry in full use
evaluate_script with `() => localStorage.getItem("key")`.

localStorage is scoped per origin, so this returns nothing on about:blank —
navigate to the site first. sessionStorage is not included.
- **get_network_request** — Inspect one network request: URL, method, resource type and both bodies.

Often the fastest way to get structured data out of a site: find the page's
own API call with list_network_requests, then read the JSON it already
received here, instead of scraping the rendered DOM.

Inline bodies are truncated at 5000 characters, so pass a file path for
anything larger. Response bodies are only available while Chrome still holds
them in its buffer — for a long-finished request the entry may still be
listed while its body is already gone.
- **handle_dialog** — Answer an open JavaScript dialog — alert, confirm or prompt.

A dialog blocks the page and every subsequent tool call until it is handled,
so call this as soon as one appears. Returns an error if no dialog is open.

beforeunload dialogs triggered by navigating away are handled automatically
via navigate_page's `handle_before_unload` parameter; this tool is for
dialogs the page opens by itself.
- **hover** — Move the mouse over an element without clicking it.

This is how you open hover-triggered menus, tooltips and dropdowns before
reading what they reveal — pass include_snapshot=true to get the revealed
content back in the same call.

The element is scrolled into view and hit-tested first, the same way click
does it: a point that lands on a sticky header instead opens that header's
menu, not the one you asked for. If something covers the element, this says
what, rather than hovering the wrong thing.
- **list_console_messages** — List console output collected for the selected page.

Requires enable_console_collection first. Console capture is opt-in because
it needs the CDP Runtime domain, which some sites use to detect an attached
debugger; without it this returns a reminder instead of messages.

Each line is prefixed with its id and truncated to 200 characters — pass the
id to get_console_message for the full text. Only the most recent 1000
messages are retained.
- **enable_console_collection** — Start capturing console output on the current page.

Call this before list_console_messages or get_console_message — neither
returns anything until collection is on. Network capture, by contrast, is
always on and needs no equivalent call.

This is opt-in rather than automatic because it enables the CDP Runtime
domain, which some anti-bot scripts probe for to detect an attached
debugger. Leave it off while stealth matters, and use
disable_console_collection when you are done debugging.

Applies per page: a new tab needs its own call.
- **disable_console_collection** — Stop capturing console output on the current page.

Turns the CDP Runtime domain back off, which restores the quieter,
harder-to-detect default — worth doing once you are finished debugging and
the page still has anti-bot checks ahead of it.

Messages already collected stay readable; only new ones stop arriving.
- **list_network_requests** — List the network requests the selected page has made.

Collection is automatic — unlike console capture, nothing needs enabling.

The main use is finding the JSON API a page calls: filter with
resource_types=["XHR", "Fetch"], then pass the id from the square brackets
to get_network_request to read the actual response body. That is usually far
cheaper and more reliable than scraping the rendered DOM.

Only the most recent 1000 requests are retained, and each URL is truncated
to 150 characters in this listing.
- **list_pages** — List every open browser tab with its index, URL and title.

The index shown here is the `page_id` taken by select_page and close_page.
Indices are positional and shift whenever a tab opens or closes, so re-read
them here rather than reusing an index from an earlier call.

Tools act on the page chosen with select_page, or on the most recently
opened tab if none was selected; the selected one is marked here.

Each entry also carries the page's CDP targetId, which is the stable
identity of a tab: unlike the index it survives other tabs opening and
closing, and it is what CDP-level tooling and logs refer to.
- **navigate_page** — Navigate the selected page — load a URL, or go back, forward or reload.

Navigating rotates the collected logs: the previous page's console and
network entries move into the preserved history (the last 3 navigations are
kept), still reachable via `include_preserved_*` on the list tools.

Passing `device` applies the emulation before the request is sent, so the
server sees mobile signals on the very first byte — calling emulate_device
afterwards is too late for that first request.

Returns the resulting URL together with all open pages. This reuses the
current tab; use new_page to open an additional one.
- **new_page** — Open a new browser tab and load a URL.

Chrome always starts with one tab, so the first call here reuses that empty
startup tab rather than leaving a stray blank page behind for the rest of
the session. A blank tab sitting among others is left alone, and an isolated
context always gets a target of its own.

Unless `background` is set, the new page becomes the selected page for every
subsequent tool call.

Passing `device` applies emulation before the first real request, so mobile
signals are present from the very first byte — which calling emulate_device
afterwards cannot achieve.
- **performance_start_trace** — Record a Chrome performance trace of page load or interaction.

Captures the same DevTools timeline categories the Performance panel uses:
rendering, scripting, loading, screenshots and the V8 CPU profile.

With the defaults (reload + auto_stop) this is one self-contained call that
reloads, records for ~5s and returns. Only one trace can run at a time.

Pass file_path to keep the data — without it you get just an event count,
which confirms the trace ran but says nothing about what it measured. The
output is raw trace JSON: load it via DevTools -> Performance -> Load profile.
- **performance_stop_trace** — Stop the running performance trace and collect its data.

Only needed when performance_start_trace was called with auto_stop=false —
otherwise the trace has already ended and this returns an error.

Waits up to 30 seconds for Chrome to flush its buffered events.
- **press_key** — Send a key press or keyboard shortcut to the page.

Goes to whatever currently holds focus, so click or fill the target element
first — with nothing focused the key lands on the document body.

Modifiers are held around the main key with the proper modifier bitmask, so
real chords such as Control+A or Control+Shift+R register as shortcuts
instead of arriving as unrelated key presses.

For entering text use fill (replaces the field) or type_text (appends to it);
this tool is for single keys and shortcuts.
- **resize_page** — Resize the browser window so the page gets the given dimensions.

Moves the actual OS window, which is what you want for responsive-layout
checks that should also come out right in a screenshot.

To emulate a viewport size without touching the window — including device
pixel ratio, the mobile flag and touch — use emulate's `viewport` parameter
or emulate_device. Those are what a site's media queries and fingerprinting
read as a genuine device change.
- **scroll_page** — Scroll the page up or down by a percentage of the viewport.

The way to trigger lazy-loaded content and infinite scroll; take a fresh
take_snapshot afterwards to see what was added.

The response reports how far the page actually moved and whether the end has
been reached, so a scroll loop has something to stop on: "did not move" means
you are at the end, and a page height that keeps growing means more content
is still loading.

To bring one known element into view, scroll_to_selector is more precise.
`click` already scrolls to its target, so no scrolling is needed before it.
- **select_page** — Choose which open tab every subsequent tool call acts on.

Without a selection, tools act on the most recently opened tab. Selecting
makes that choice explicit and sticky — needed when a click opened a tab you
now want to drive, or when working across several sites at once.

Indices come from list_pages and shift as tabs open and close, so the
response lists them again. If the selected tab is later closed, the
selection is dropped and the default applies again.
- **set_cookie** — Set a single browser cookie.

Useful for injecting a known session token, consent flag or A/B bucket
without walking through a login flow.

This creates a session cookie: no expiry, gone when the browser closes. To
restore a full cookie set including expiry and SameSite, use load_session.
Cookies are browser-wide, not per tab.
- **set_local_storage** — Write entries into the current page's localStorage.

Merges into what is already there rather than clearing it. Handy for setting
feature flags, consent state or auth tokens before the page's own scripts
read them.

localStorage is scoped per origin, so navigate to the site first — writing
on about:blank goes nowhere. Most pages read these values only at startup,
so reload after setting them.
- **take_memory_snapshot** — Capture a V8 heap snapshot of the page, for memory-leak debugging.

Writes the raw .heapsnapshot file for Chrome DevTools -> Memory -> Load. The
usual workflow is two snapshots taken around a suspect interaction, then
comparing retained objects between them.

On a heavy page a snapshot can be hundreds of megabytes and take several
seconds; the response reports the resulting file size.
- **take_screenshot** — Capture the page, the viewport or a single element as an image.

Do NOT use this to read a page. take_snapshot gives you the same content as
searchable text, is dramatically smaller, and yields the uids every
interaction tool needs. Use a screenshot only when you genuinely need
pixels: layout and styling checks, visual regression, or text that exists
only inside an image.

Element capture needs a uid from the current snapshot. Without file_path the
image is returned inline as a base64 data URL.
- **take_snapshot** — Read the page as compact text, with a uid for every element.

This is the primary way to see a page, and the source of the uids that
click, fill, hover, drag and upload_file all take. Prefer it over
take_screenshot for anything but a genuine visual check: it is searchable,
far smaller, and it is what makes interaction possible at all.

The output is the accessibility tree — roles, names, values and states,
indented by nesting — with Chrome-internal and purely presentational nodes
filtered out unless `verbose` is set.

A URL on the same origin as the page is printed relative to it — the root node
carries the absolute one, so nothing is lost and a link-dense page costs
noticeably less to read.

uids stay stable across snapshots for elements that did not change, but any
page change can invalidate them. Whenever a tool reports "unknown uid", take
a fresh snapshot and use the new uid. Output is capped at 200 000 characters.

Frames are included and marked, so an element inside an iframe gets a uid like
any other and click, fill and evaluate_script work on it unchanged.

For plain page text without uids, get_page_content is cheaper; to find
elements by CSS selector, use query_selector.
- **type_text** — Type text into whatever element currently has focus.

Appends at the caret instead of replacing, and needs something focused
already — click the field first, or use fill, which takes a uid, clears the
field and needs no separate focus step.

Prefer fill for ordinary form filling. Use type_text when you must add to
existing content, or for widgets that only react to raw key events such as
contenteditable, rich-text and canvas editors.
- **upload_file** — Attach a local file to a file input on the page.

Sets the input's files directly over CDP, so no OS file-picker dialog ever
opens — clicking an upload button normally would open one, and that blocks
every further tool call until a human dismisses it.

Chrome renders a file input with an internal shadow button, and that is what
the accessibility tree exposes, so the uid you can see is usually not the
input itself. This resolves to the real input: the element, one inside it,
the input a label controls, or one just above in the tree.

The result is read back. If the page's own uploader consumed the file
straight away, the response says so, which is a successful upload rather
than an empty input.
- **wait_for** — Wait until one of several texts appears on the page, then snapshot it.

The right way to wait after an action that starts loading — far more
reliable than guessing a delay, and it returns the moment the text shows up
instead of always burning the whole timeout.

On success the page snapshot is included in the response, so no separate
take_snapshot call is needed.

Polls the visible text twice a second. To wait for an element rather than
for wording, use wait_for_selector.
- **save_session** — Save cookies, localStorage and open page URLs to a reusable file.

This is how you keep a login obtained interactively, so a later run can skip
the login flow entirely — restore it with load_session.

Stored as JSON under ~/.nodriver-mcp/sessions/. That file holds live session
tokens in plain text, so treat it as a credential.

Only the current page's origin contributes localStorage. For a login meant
to survive without an explicit restore step, a persistent profile
(create_profile + use_profile) is the sturdier option.
- **load_session** — Restore cookies and localStorage from a saved session file.

Use it at the start of a run to arrive already logged in. The page is first
navigated to the saved origin so localStorage lands where it belongs, then
reloaded so the restored cookies take effect.

Cookies that no longer apply are skipped rather than failing the whole
restore, and the response reports how many were actually restored. Expired
tokens still leave you logged out, so check the page afterwards.
- **list_sessions** — List saved session files with their name, save time and contents.

Shows the filename to hand to load_session, along with how many cookies and
localStorage entries each one holds, newest first.

Sessions live in ~/.nodriver-mcp/sessions/ and are never cleaned up
automatically, so old logins accumulate there over time.
- **get_page_content** — Get the page's visible text, or its full HTML.

The cheapest way to read a page when you only need content and not the uids
take_snapshot provides: no accessibility tree is built, and the text form
carries no markup overhead.

Returns the DOM as it stands right now, so on pages that render
asynchronously call wait_for or wait_for_selector first.

Use take_snapshot when you intend to interact with elements, and
query_selector when you want specific elements rather than the whole page.
- **query_selector** — Find elements by CSS selector; list their tag, text, href, id and class.

The efficient way to pull a repeated structure off a page — search results,
product tiles, table rows — without paying for a full snapshot.

Returns a compact listing only. It yields no uids, so it cannot drive clicks:
take a snapshot when you need to interact, or operate on the elements
directly via evaluate_script.

Element text is truncated to 200 characters.
- **get_computed_styles** — Read an element's computed styles, as the browser actually resolved them.

Computed styles are the end result of the whole cascade, so this answers
what a stylesheet alone cannot: why an element is invisible
(`display: none`, `visibility: hidden`, `opacity: 0`), what a CSS variable
resolved to, or which font actually got used.

Also reports the element's box: position, size, and whether it is currently
in the viewport. An element with zero width or height is present in the DOM
but not rendered, which is the usual reason a click appears to do nothing.

By default only properties that differ from the browser default are
returned, because the full set runs to several hundred entries and buries
the interesting ones.
- **scroll_to_selector** — Scroll the first element matching a CSS selector into view, centered.

More precise than scroll_page when you already know what you are looking
for, and the usual preparation for click_at, which needs its target inside
the viewport.

`click` scrolls to its own target, so this is unnecessary before it. Reports
whether anything matched instead of failing silently.
- **block_resources** — Block images, fonts, stylesheets or media to speed up page loads.

Removes most of the bytes on a media-heavy page, which makes scraping
several times faster and far cheaper over a metered or proxied connection.

Blocking stylesheets breaks layout, so anything that depends on element
geometry becomes unreliable — click_at, element screenshots, and the
`visible` check in wait_for_selector. Text extraction is unaffected.

Requests are matched on the resource type Chrome reports for them, so an
asset served from an extension-less URL is still blocked, and blocking one
type never catches another.

Applies to ONE TAB — the selected page — and stays in effect there across
navigations until called again with no types. A tab opened afterwards with
new_page starts unblocked and needs its own call.
- **capture_bodies** — Save every matching response body to disk the moment it arrives, in the background.

get_network_request asks Chrome for a body only when you ask — by then it is
often gone: Chrome drops bodies over ~10 MB at once, and a navigation to
another site drops the rest. This takes each body while the response is
still paused, before the page even sees it, and writes it to a file.

Covers the whole browser until stopped: every tab, including ones the page
opens itself, plus dedicated workers and service workers. Each body is its
own file; capture.jsonl beside them records URL, method, POST data, status
and headers per file. list_network_requests marks captured requests "saved",
and get_network_request falls back to the file.

Not captured: responses from Chrome's cache (they never touch the network —
reload with ignore_cache), redirects, and event streams inside workers.
Event streams on a page are written as they arrive; every other matching
response reaches the page only once complete, so keep the pattern off
long-running downloads.
- **export_har** — Write the network log to a HAR 1.2 file — importable into Burp, ZAP,
Chrome DevTools, Charles and anything else that reads HAR.

Headers are the ones that went over the wire, Cookie and Set-Cookie
included, with POST bodies, status, timing, server IP and WebSocket frames.
Response bodies come from capture_bodies' files where available — start a
capture first for a HAR that has every body — and otherwise from Chrome's
buffer, which only still holds recent ones on the current tab.

The file holds live session cookies and tokens; treat it like a password.
- **audit_security** — Passive security review of the selected page, from what the browser
already has — a starting point for a manual test, not a verdict.

WARN marks a concrete weakness, INFO context worth a look. Headers come from
the recorded page load; if it was not recorded (a tab the server did not
open), the page URL is fetched once from the page and the answer says so.
Chrome's issues are collected from the moment the server first touches a
tab, so on a tab it had not touched, reload the page for a full list.
Cookie values are never printed — use get_cookies for those.
- **inspect_storage** — Everything the selected page's origin keeps in the browser: localStorage,
sessionStorage, IndexedDB, Cache Storage, service workers and quota usage.

Flags values that look like credentials — by key name, JWT shape or a
"Bearer" prefix, also inside JSON values — because anything in web storage
is readable by every script on the origin. JWTs are decoded (algorithm,
expiry, claims; the signature is not checked).

Read through DevTools, not page JavaScript, except for the list of service
worker registrations. IndexedDB and Cache Storage are listed with their
sizes, not dumped; use evaluate_script to read records.
- **search_bodies** — Find where a value shows up in the traffic capture_bodies recorded — in
which response it first arrived, and which later requests sent it back.

The usual question when following data through an app: where did this ID,
token or price come from, and where does it go next. Results are in capture
order, so the first hit is the first response that carried it; POST data
and URLs from capture.jsonl are searched too, as the "sent" side.
- **wait_for_selector** — Wait until an element matching a CSS selector appears on the page.

The structural counterpart to wait_for: use it when you know the markup but
not the wording, or when the wording is localised.

Polls about three times a second and returns as soon as the element shows
up. Unlike wait_for it does not return a snapshot, so follow with
take_snapshot when you intend to interact.
- **save_pdf** — Export the current page to a PDF using Chrome's print-to-PDF.

Renders the entire document rather than just the viewport, and applies the
page's print stylesheet — so the result can differ from the screen layout.

A good way to archive a rendered page as one file. For a pixel-accurate copy
of what is on screen, use take_screenshot with full_page instead.
- **clear_cookies** — Delete every cookie in the browser.

Browser-wide, not per site and not per tab — this logs you out of everything
at once, with no undo. Restore a previous state with load_session if you
have one saved.

Useful for testing a first-time-visitor flow or resetting a consent banner
decision. localStorage is left untouched, so sites that keep state there may
still recognise you.
- **set_browser_flags** — Change Chrome's launch flags at runtime, and show the current ones.

Call with no arguments at all to just read the effective configuration —
that form changes nothing.

These are launch-time flags, so applying them restarts Chrome and closes
every open page; on an ephemeral profile that also drops its cookies. Values
set here override the NODRIVER_ENABLE_* environment variables.
- **set_proxy** — Send this browser's traffic through a proxy, with credentials if it wants them.

The proxy itself is a launch flag, so it needs a browser restart — which on an
ephemeral profile also drops its cookies. Authentication is handled live: an
authenticating proxy stops Chrome at a native dialog that no page and no other
CDP command can dismiss, so the page just never loads and nothing says why.

Answering it means enabling the Fetch domain, which pauses every request for a
round trip, so that only happens when a username is actually set. A proxy that
does not ask for credentials costs nothing extra.

Call with no arguments to see what is configured. The password is never echoed
back. Values set here override the NODRIVER_PROXY environment variable.
- **manage_extensions** — List, enable, disable or load Chrome extensions.

Two separate mechanisms are in play. The master switch: Chrome runs with
--disable-extensions by default, so extensions installed in the profile stay
dark until it is turned on. And unpacked extensions loaded from a folder on
disk. The master switch governs both — with it off, unpacked extensions stay
registered but do not load.

"load" only works on Chromium or Chrome for Testing. Official Google Chrome
builds have ignored --load-extension since v137 (still true on Chrome 151,
even with --enable-unsafe-extension-debugging), and this tool says so rather
than pretending it worked. On official Chrome the working path is: install
the extension once from the Web Store into a persistent profile, then switch
it on with "on".

Extensions only persist in a persistent profile — on the default ephemeral
one nothing can stay installed.
- **list_profiles** — List persistent Chrome profiles and show which one is active.

By default the browser runs on a fresh ephemeral temp profile that is
deleted when the session ends. That default is what lets several nodriver
instances — Claude Desktop, Claude Code, the VS Code extension — run at the
same time without fighting over one profile directory.

Persistent profiles keep cookies, logins and installed extensions across
sessions. Create one with create_profile, switch with use_profile, and
return to ephemeral with use_temp_profile.
- **create_profile** — Create a named persistent Chrome profile — a reusable user-data dir.

A persistent profile keeps cookies, logins and Web Store extensions between
runs, so a site you log into once stays logged in. It is the sturdier
alternative to save_session / load_session.

Creating one is harmless by itself: nothing changes until you activate it,
here or via use_profile. An existing profile of the same name is left
untouched rather than overwritten. Profiles live under
~/.nodriver-mcp/profiles/<name>.

Only one browser instance can use a given profile at a time, so give
concurrent setups different names.
- **use_running_browser** — Drive a Chrome that is already running, instead of launching a new one.

This is how you use a browser you are already signed into. Chrome locks its
user-data-dir, so the profile holding your real logins cannot be opened a
second time — attaching to the running instance is the only way in, and it
saves rebuilding every login through the automation.

The user starts Chrome themselves, once:

    chrome --remote-debugging-port=9222 --user-data-dir=/path/to/profile

then this tool attaches to it. Every tool then acts on that browser and its
real tabs.

`--user-data-dir` is not optional, and it must not be Chrome's own default
directory. Since Chrome 136 the port is refused there — Chrome starts
normally, says nothing, and nothing ever listens, which is a confusing way
to fail. So the everyday profile cannot be reached by adding the flag to
it; it has to be a directory of its own, and a copy of an existing profile
is accepted (`--profile-directory` then picks which profile inside it).

SECURITY: that profile becomes part of the agent's reach. Whatever it is
signed into — mail, bank, company systems — is reachable from here, because
a cookie jar is all or nothing. Point this at a profile you are willing to
expose, not your everyday one.

While attached, close_browser and every profile switch only detach; they
never close a browser they did not start. Return to a self-launched browser
with use_temp_profile or use_profile.
- **use_temp_profile** — Switch back to a fresh ephemeral profile, created and deleted per session.

This is the default. It leaves nothing behind on disk and lets many nodriver
instances run concurrently without colliding on a profile directory.

Restarts the browser and closes all open pages. The profile you are leaving
is not deleted — a persistent one keeps its cookies for the next
use_profile. Anything held in the outgoing temp profile is gone.
- **use_profile** — Switch the browser to a named persistent profile.

Use it to pick up the cookies, logins and extensions stored in an earlier
session, so a run starts out already authenticated.

Restarts the browser and closes every open page. The profile has to exist
already — create it first with create_profile. Only one browser may use a
profile at a time; a second instance pointed at the same one fails to start.
- **delete_profile** — Permanently delete a persistent Chrome profile directory.

Irreversible: the profile's cookies, logins, history and installed
extensions are removed from disk, with no undo.

The active profile cannot be deleted — switch away with use_temp_profile
first.
- **list_browsers** — List every browser this server currently holds: its name, whether its Chrome is running, which profile it uses, how many tabs it has and what they show. Reports without starting anything.

There is always a browser called "default", which runs in the server process. Others are created implicitly, by passing a new name as the `browser` argument of any tool, so this only shows names already used. A name missing here is not an error; it simply has not been opened.

chrome_running=false means the browser exists but has no Chrome, either because no tool has needed one yet or because close_browser was called. The next call relaunches it.
- **shutdown_browser** — Quit one browser's Chrome and free its name, releasing all memory behind it. Use it when an agent is finished with its browser.

The difference to close_browser: close_browser quits Chrome but keeps the browser, so its selected profile and runtime flags survive and the next call relaunches Chrome with them. shutdown_browser discards that too, and the name starts fresh if it is ever used again.

The "default" browser cannot be retired, since it is the server itself; shutting it down quits its Chrome and resets it. A browser you attached to with use_running_browser is only detached from, never closed: it belongs to the user.

## Claude Desktop Quick Installation
Install path detected from listing signals. Uses `uvx` (confidence: high):

```json
"mcpServers": {
  "nodriver-undetected-browser-automation": {
    "command": "uvx",
    "args": ["nodriver-mcp"]
  }
}
```

## Documentation & README

<p align="center">
  <img src="https://raw.githubusercontent.com/andresolbach/nodriver-mcp-server/main/assets/logo.png" alt="nodriver-mcp — browser-tab monogram" width="130" height="130">
</p>

# nodriver-mcp-server

<!-- mcp-name: io.github.andresolbach/nodriver-mcp-server -->

**Give your AI agent a full Chrome workspace.** Browse, fill forms, extract data and inspect the requests behind a page with **70 MCP tools**, powered by [nodriver](https://github.com/ultrafunkamsterdam/nodriver). Keep your logins, run isolated browsers side by side, and pick up where you left off with automatic tab restoration. One-command setup for **Claude, Claude Code, Cursor, Windsurf, Codex** and other [Model Context Protocol](https://modelcontextprotocol.io) clients.

[![PyPI](https://img.shields.io/pypi/v/nodriver-mcp.svg)](https://pypi.org/project/nodriver-mcp/)
[![CI](https://github.com/andresolbach/nodriver-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/andresolbach/nodriver-mcp-server/actions/workflows/ci.yml)
![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)
![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)
![MCP compatible](https://img.shields.io/badge/MCP-compatible-purple.svg)
![Tools: 70](https://img.shields.io/badge/tools-70-orange.svg)
![Stars](https://img.shields.io/github/stars/andresolbach/nodriver-mcp-server?style=social)

> **Keywords:** MCP server · browser automation · undetected chromedriver · anti-bot · Cloudflare bypass · web scraping · Claude · Cursor · nodriver · chrome-devtools-mcp alternative · Playwright/Puppeteer alternative · AI agent tools.

## More browsing, less setup

- **Log in once, come back ready.** Persistent profiles keep cookies and logins between sessions.
- **Start your way.** Open a fresh tab, a set of favorite URLs, or the tabs you left open — configurable through MCP, per profile.
- **Give each task its own browser.** Run up to 12 isolated browser sessions without sharing tabs, cookies or the selected page.
- **Get the data behind the page.** Read accessibility snapshots, inspect API traffic and save response bodies for later search.
- **Automate real Chrome directly.** nodriver, the successor of `undetected-chromedriver`, uses CDP without Selenium or a ChromeDriver binary and is designed to reduce common automation signals.

Explore the [70-tool reference](https://github.com/andresolbach/nodriver-mcp-server/blob/main/docs/TOOLS.md), or get started with `uvx nodriver-mcp`.

## Does it actually pass? Here is the evidence

Claiming "undetected" is easy, so here is what the standard fingerprint suite reports when this server drives the page itself. Reproduce it in one line: point your agent at `https://bot.sannysoft.com` and take a snapshot.

<p align="center">
  <img src="https://raw.githubusercontent.com/andresolbach/nodriver-mcp-server/main/assets/proof/sannysoft.png" alt="bot.sannysoft.com results: all checks green" width="700">
</p>

| Check | Result |
|---|---|
| `bot.sannysoft.com` — Intoli + fingerprint suite | **58 checks, 0 failed** |
| `navigator.webdriver` | `false` — a boolean on `Navigator.prototype`, not an own property, exactly as in a normal browser |
| `window.chrome` | present |
| `navigator.plugins` | 5 |
| `navigator.languages` | 4 entries |
| User agent | no `Headless` token |

Measured on Chrome 151 / Windows 11, headful, with the default profile. The current functional test suite uses Chrome 155 and nodriver 0.50.6. See [Scope and limitations](#scope-and-limitations) for how to interpret the fingerprint result.

## Features

- 🕵️ **Direct CDP automation** — nodriver avoids Selenium and a ChromeDriver binary and is designed to reduce common automation signals.
- ☁️ **Cloudflare checkbox helper** (`cf_verify`, needs the `[cf]` extra); success depends on the challenge and browser configuration.
- 🖼️ **Frames are not a blind spot** — `list_frames` plus a `frame` argument on the readers, and `take_snapshot` splices each iframe's tree in under the element hosting it, so a payment field or consent wall inside one gets a uid and can be clicked and filled like anything else.
- 🔍 **A network log that says what happened** — status, response headers, a DevTools-style timing breakdown (DNS, connect, TLS, time to first byte, download), the script and line that started each request, transfer size, redirect hops and failures, plus WebSocket frames and server-sent events. A 500, a 404, a redirect and a DNS failure are different lines, not the same one.
- 💾 **Response capture to disk** — `capture_bodies` captures matching responses from tabs, workers and service workers before later navigation or buffer eviction can discard them. A JSON index records URL, POST data, status and headers; `search_bodies` searches saved traffic. Capture status reports saved bodies, skips and errors.
- 🛡️ **Passive security review built in** — `audit_security` checks security headers (with a real CSP analysis: nonces, `strict-dynamic`, multiple policies), CORS, TLS and certificates of every origin, cookie flags, responses a CDN could serve to the wrong user, every first and third party the page talks to, and lists what Chrome's own Issues panel reports: CSP violations, mixed content, rejected cookies. `inspect_storage` inventories localStorage, sessionStorage, IndexedDB, Cache Storage and service workers, and decodes tokens it finds there. `export_har` writes everything to a HAR file for Burp, ZAP or DevTools — with the wire headers, Cookie and Set-Cookie included.
- 👥 **Several isolated browsers at once** — pass `browser: "agent-a"` to any tool and that name gets its own Chrome, in its own process, with its own cookies, tabs and uids. Parallel agents stop stealing each other's selected tab. Omit it and nothing changes: the default browser runs in the server process, exactly as before. [How it works](#several-agents-several-browsers)
- 🧩 **70 tools** covering navigation, input, snapshots, screenshots, content/PDF export, network + console inspection, device emulation, cookies/storage, sessions, profiles, and performance tracing.
- 🧠 **Schemas written for the agent, not just the compiler** — every parameter carries a description, fixed-value options are real enums, and each tool declares read-only/destructive hints. See [why this matters](#built-for-the-agent-that-calls-it).
- 📄 **Compact accessibility-tree snapshots** (`take_snapshot`) — searchable page text with a uid per element. Measured on the Hacker News front page: **27 KB against 97 KB unfiltered, 72% smaller, with every link, URL and text preserved.** Same-origin URLs are printed relative to the page, which is lossless because the root node keeps the absolute one. That saving lands on every single agent step.
- 🔗 **Attach to a browser you are already signed into** (`use_running_browser`) — drive your real Chrome profile over its debugging port instead of rebuilding logins in a fresh one.
- 📱 **Device emulation** (Pixel 7, iPad) with correct UA / client hints.
- ✅ **Actions that verify themselves** — `fill` reads the value back, `set_checked` reads the checked state back, and `click`, `press_key` and `type_text` report when no input event reached the page. A tool that cannot confirm what it did says so instead of reporting success.
- 💾 **Session save/restore** — persist logins across runs.
- 🧬 **Ephemeral by default, persistent when needed** — each session gets its own temporary Chrome profile. Named persistent profiles retain logins and offer configurable startup: a new tab (default), specified URLs, or the previous tabs without a restore-button click.
- ⚡ **One-command setup** for 15+ MCP clients.

## Installation

```bash
# Recommended: isolated install, won't touch your global Python environment
uv tool install nodriver-mcp

# or with pip
pip install nodriver-mcp

# or run it without installing anything
uvx nodriver-mcp
```

Requires [uv](https://docs.astral.sh/uv/getting-started/installation/) for the first and third form. To track the development branch instead of a release:

```bash
uv tool install "nodriver-mcp @ git+https://github.com/andresolbach/nodriver-mcp-server.git@main"
```

Also listed in the [official MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.andresolbach/nodriver-mcp-server`, so clients and marketplaces that read the registry can find and install it directly.

> Uses upstream [`nodriver`](https://pypi.org/project/nodriver/) `>=0.50.6`, tested against Chrome 155. `uv tool install` keeps dependencies isolated from your global Python environment.

You'll also need a local installation of **Google Chrome** (auto-detected).

### Upgrade

```bash
uv tool upgrade nodriver-mcp
```

## Requirements & tested versions

The automated suite runs schema, documentation and behavior checks plus real-browser integration tests. The current Windows browser test environment is **Chrome 155 / nodriver 0.50.6 / Python 3.12.11**. CI also runs the non-browser suite on macOS and Linux. Future Chrome or nodriver releases can require compatibility updates.

| Component | Requirement | Verified version |
|-----------|-------------|------------------|
| Python | 3.12+ | 3.12.11 |
| Google Chrome | any recent stable | 155.0.8059.79 |
| Operating system | Windows / macOS / Linux | Windows 11 |
| `nodriver` | >= 0.50.6 | 0.50.6 |
| `mcp` (MCP SDK) | >= 1.26.0, < 2 | 1.26.0 |
| `pillow` | >= 12.1.1 | 12.1.1 |
| `tomli-w` | >= 1.0.0 | 1.2.0 |

Runtime dependency ranges and tested versions are listed in [`requirements.txt`](https://github.com/andresolbach/nodriver-mcp-server/blob/main/requirements.txt). `uv tool install` creates an isolated installation; contributors can use `uv sync --locked --group dev` in this repository to reproduce the committed dependency set.

## One-command MCP client setup

```bash
# Interactive client selector (terminal TUI)
nodriver-mcp install

# Install to specific clients
nodriver-mcp install claude,cursor,kiro

# Uninstall
nodriver-mcp uninstall claude

# List all supported clients
nodriver-mcp --list-clients

# Print MCP config JSON (for manual setup)
nodriver-mcp --config

# Project-level config (writes to .cursor/mcp.json, .mcp.json, etc.)
nodriver-mcp install --scope project
```

**Supported clients:** Claude Desktop, Claude Code, Cursor, Windsurf, Codex, Gemini CLI, Copilot CLI, Kiro, VS Code, Cline, Roo Code, Amazon Q, Warp, Opencode, Trae.

> The Claude Code VS Code extension shares Claude Code's config (`~/.claude.json`), so installing to `claude-code` covers both the CLI and the extension.

### Manual config

If you'd rather paste it yourself, this works in any MCP client (`claude_desktop_config.json`, `~/.claude.json`, `.cursor/mcp.json`, `.mcp.json`, …):

```json
{
  "mcpServers": {
    "nodriver": {
      "command": "uvx",
      "args": ["nodriver-mcp"]
    }
  }
}
```

## Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `NODRIVER_HEADLESS` | Headless mode (`true`/`false`) | `false` |
| `NODRIVER_USER_DATA_DIR` | Explicit persistent Chrome profile dir (overrides the default) | Ephemeral temp profile, auto-deleted per session |
| `NODRIVER_BROWSER_PATH` | Chrome executable path | Auto-detected |
| `NODRIVER_PROXY` | Proxy server address | None |
| `NODRIVER_BROWSER_URL` | Attach to a Chrome already running at this address (e.g. `http://127.0.0.1:9222`) instead of launching one | Launch our own |
| `NODRIVER_ENABLE_TRANSLATE` | Set `true` to re-enable Chrome's Google Translate popup | Disabled |
| `NODRIVER_ENABLE_EXTENSIONS` | Set `true` to allow externally-installed Chrome extensions (and their prompts) | Disabled |

By default the browser starts clean for automation: the **Google Translate popup is suppressed** and **externally-installed Chrome extensions are blocked** (so you don't get "an extension requires your attention" prompts). Re-enable either via the env vars above **or at runtime with the `set_browser_flags` tool** — which can also set **any other Chrome launch flags** (e.g. `--lang=de-DE`, `--window-size=1280,800`) via its `extra_args` parameter. The browser also **auto-recovers** if Chrome is closed or crashes between calls — tools relaunch it instead of failing.

Ephemeral browsers start on `about:blank`, keeping New Tab page traffic out of the network log. Persistent profiles default to a new tab and can instead open specified URLs or restore their previous tabs; see [Profiles](#profiles). `new_page` reuses an empty startup tab.

## Extensions

`manage_extensions` handles extensions at runtime:

- `manage_extensions("list")` — extensions installed in the active profile (name, version, id) plus the current state
- `manage_extensions("on")` / `("off")` — the master switch (`--disable-extensions`); restarts Chrome. It covers unpacked extensions too, so `"off"` really means off
- `manage_extensions("load", path)` / `("unload", path)` — unpacked extensions from a folder

To use an extension permanently, switch to a persistent profile, install it once from the Chrome Web Store in that browser, and turn extensions on — it then loads on every launch.

> **Unpacked extensions need Chromium or Chrome for Testing.** Official Chrome builds dropped `--load-extension` in v137, and as of Chrome 151 neither `--enable-unsafe-extension-debugging` nor disabling `DisableLoadExtensionCommandLineSwitch` brings it back — the flag is accepted and the extension is silently never registered. `manage_extensions` detects a branded build and says so instead of pretending it worked. Point `NODRIVER_BROWSER_PATH` at Chromium / Chrome for Testing if you need unpacked loading.

## Several agents, several browsers

One agent working alone can skip this section: without the `browser` argument everything runs on a single shared browser, exactly as before.

Point **two agents** at one browser, though, and they collide. They share the selected tab, so a `select_page` or a navigation by one silently changes what the other sees, and every `uid` the other is holding goes stale. Nothing errors — the second agent simply acts on the wrong page.

So every tool takes an optional `browser` argument, and a name that does not exist yet creates one on the spot:

```jsonc
{ "url": "https://example.com", "browser": "agent-a" }
```

Each name is a **separate Chrome in a separate process**, with its own profile, cookies, tabs, snapshot uids and console/network capture. Isolation is structural rather than careful: there is no shared state left to collide over, and a Chrome that hangs or crashes takes down nothing but its own browser.

The default costs nothing. `"default"` runs inside the server process itself, so a session that never opens a second browser is the same single process and the same code path it always was. Only extra names spawn anything.

| Tool | |
|---|---|
| `list_browsers` | What is open: names, whether Chrome runs, profile, tabs and their URLs. Reports without starting anything. |
| `shutdown_browser` | Quits one browser's Chrome and frees its name. `close_browser` only quits Chrome and keeps the browser's profile and flags for next time. |

Each extra browser is a Python process plus a full Chrome — roughly 200 MB and about a second to start. Up to **12** can be open at once, including the default. Two callers sharing one *name* still share one Chrome: the isolation is per browser, not per caller, so parallel agents each need their own.

## Profiles

By default every browser launches Chrome with a **fresh temporary profile** that is created and deleted automatically. That also means you can run nodriver from **Claude Desktop, Claude Code and the VS Code extension at the same time** — each gets its own isolated Chrome, and they never fight over a shared profile. No configuration, no detection logic, nothing to clean up.

When you want to **reuse a login across sessions**, create a named persistent profile and switch to it:

- `list_profiles` — list persistent profiles and show the active one
- `create_profile(name, activate=false, restore_tabs=false)` — create a reusable profile; starts with a new tab by default
- `use_profile(name)` — switch to a persistent profile (`""`/`"temp"` returns to ephemeral)
- `use_temp_profile` — switch back to a fresh ephemeral profile
- `delete_profile(name)` — remove a persistent profile

Persistent profiles live under `~/.nodriver-mcp/profiles/<name>`. Startup defaults to a new tab. Select a profile with `use_profile(name)`, then choose its startup behavior through MCP:

```python
set_browser_flags(startup_mode="new_tab", restart=False)
set_browser_flags(startup_mode="urls", startup_urls=["https://example.com", "https://example.org"], restart=False)
set_browser_flags(startup_mode="restore", restart=False)
set_browser_flags()  # read the saved choice without changing anything
```

Each setting is saved in that profile's `nodriver-startup.json`, survives MCP restarts, and takes precedence over Chrome's startup preference. `restart=False` leaves current tabs open and applies the choice on the next browser launch; the default `restart=True` closes Chrome immediately so the next action uses the new setting. `restore` resumes the last open tabs without clicking a recovery prompt or adding an extra blank tab. With no saved MCP choice, explicit Chrome startup preferences are respected. Temporary profiles still start blank and cannot save these startup settings. After restarting the MCP server itself, select the same profile again with `use_profile(name)`; profile selection is per server session. You can still force a fixed profile globally with the `NODRIVER_USER_DATA_DIR` env var.

## Drive a browser you are already signed into

Chrome locks its user-data-dir, so the profile holding your real logins cannot be opened a second time. The way in is to attach to the browser that already has it open — then you skip rebuilding every login through automation.

Start Chrome yourself, once:

```bash
# Windows
chrome.exe --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\agent-profile"
# macOS / Linux
google-chrome --remote-debugging-port=9222 --user-data-dir=~/.config/agent-profile
```

Then attach, either at runtime with `use_running_browser(port=9222)`, or up front by setting `NODRIVER_BROWSER_URL=http://127.0.0.1:9222`. Every tool then acts on that browser and its real tabs.

While attached, this server **never closes a browser it did not start**: `close_browser` and profile switches only detach. Go back to a self-launched browser with `use_temp_profile` or `use_profile`.

> ⚠️ **That profile becomes part of the agent's reach.** Whatever it is signed into — mail, bank, company systems — is reachable from here, because a cookie jar is all or nothing. Point this at a profile you are willing to expose, not your everyday one.

## Tools (70)

Network collection is enabled automatically on each tab. Console collection is opt-in: call `enable_console_collection` when you want `list_console_messages` / `get_console_message` to start collecting events. This keeps `Runtime.enable()` disabled by default for sites that detect attached debuggers.

For mobile-only sites, pass `device` directly to `new_page(...)` or `navigate_page(...)` so the first real request already carries mobile signals.

`click` sends **real CDP input events**, so the page sees `isTrusted=true`. Because those are delivered by coordinate, it scrolls the element into view and then hit-tests several points inside it, since a sticky header or cookie banner can own the centre pixel — on `docs.pypi.org` that affects 43 of 54 visible links. If no point reaches the element, `if_covered` decides: `"report"` (default) refuses and names the blocker, leaving the page untouched and the session undetectable; `"synthetic_click"` clicks the element directly, which works through anything but makes the page see `isTrusted=false`. Either way the response says which path was taken, so a detectable click is never silent.

| Category | Tools |
|----------|-------|
| **Several browsers (2)** | `list_browsers` · `shutdown_browser` |
| **Input automation (12)** | `click` · `click_at` · `hover` · `fill` · `fill_form` · `set_checked` · `select_option` · `type_text` · `press_key` · `drag` · `upload_file` · `handle_dialog` |
| **Navigation (10)** | `navigate_page` · `new_page` · `close_page` · `close_browser` · `list_pages` · `select_page` · `wait_for` · `wait_for_selector` · `scroll_page` · `scroll_to_selector` |
| **Snapshots & debugging (12)** | `take_screenshot` · `take_snapshot` · `get_page_content` · `query_selector` · `list_frames` · `evaluate_script` · `get_computed_styles` · `save_pdf` · `enable_console_collection` · `disable_console_collection` · `list_console_messages` · `get_console_message` |
| **Network monitoring (6)** | `list_network_requests` · `get_network_request` · `capture_bodies` · `search_bodies` · `export_har` · `block_resources` |
| **Security audit (2)** | `audit_security` · `inspect_storage` |
| **Device emulation (4)** | `emulate` · `emulate_device` · `reset_emulation` · `resize_page` |
| **Performance (3)** | `performance_start_trace` · `performance_stop_trace` · `take_memory_snapshot` |
| **Cookies & storage (5)** | `get_cookies` · `set_cookie` · `clear_cookies` · `get_local_storage` · `set_local_storage` |
| **Session management (3)** | `save_session` · `load_session` · `list_sessions` |
| **Profiles & browser (9)** | `list_profiles` · `create_profile` · `use_profile` · `use_temp_profile` · `use_running_browser` · `delete_profile` · `set_browser_flags` · `set_proxy` · `manage_extensions` |
| **Anti-detection helpers (2)** | `cf_verify` · `bypass_insecure_warning` |

📖 **[Full tool reference →](https://github.com/andresolbach/nodriver-mcp-server/blob/main/docs/TOOLS.md)** — every tool with its exact parameters, types, defaults and enum values, generated straight from the live schemas.

## Built for the agent that calls it

An MCP tool is only as good as what the model can see of it. Most servers hand over a name, a sentence, and untyped parameters — leaving the agent to guess whether it's `type="url"` or `type="goto"`, and burning a failed call to find out.

Here, the schema does that work:

- **Every parameter has a description in the schema itself** — not buried in a prose blob the client may never show. All 70 tools, all parameters, no exceptions (there's a test for it).
- **Fixed-value parameters are real enums.** `navigate_page(type=…)` advertises exactly `url`, `back`, `forward`, `reload`. A wrong value is rejected by validation before it ever reaches Chrome, instead of returning an error the agent has to interpret.
- **Numeric and array bounds are declared** — `quality` is 0–100, `wait_for(text=…)` requires at least one entry.
- **Structured parameters are typed.** `fill_form` publishes `{uid, value}` rather than an opaque `list[dict]`.
- **Every tool declares behaviour hints** (`readOnlyHint`, `destructiveHint`, `idempotentHint`). Clients use these to group permissions — so a client can auto-approve `take_snapshot` while still prompting for `delete_profile`.
- **Descriptions say when *not* to use a tool**, and point at the better one. `take_screenshot` tells the model to prefer `take_snapshot`; `click_at` points back to `click`.

## Relationship to chrome-devtools-mcp

This project uses nodriver and Python. [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) is a separate implementation with its own evolving tool API. Compare the current tool references when choosing a server; matching names do not imply identical arguments or behavior.

This server does not implement `performance_analyze_insight`, `lighthouse_audit` or `screencast_start/stop`. Extension management is available through `manage_extensions`, subject to the Chrome build restrictions described above.

## Use cases

- **Browse and extract structured content** using a real Chrome session, with network and response-body inspection when the data comes from an API.
- **Let an AI agent browse the real web** — Claude, Cursor, Windsurf and other LLM agents can log in, fill forms, click, read pages, and screenshot.
- **Automate authenticated workflows** and reuse the login across sessions with persistent profiles.
- **LLM-driven web research & data extraction** using compact accessibility-tree snapshots instead of brittle screenshots.
- **End-to-end / QA testing** with device emulation, network + console inspection, and performance traces.
- An **undetected alternative to Playwright, Puppeteer and Selenium** for agentic browsing.

## FAQ

**Is this an undetected alternative to chrome-devtools-mcp?**
It is an alternative built on nodriver, with familiar browser tools and additional profile and network features. It is not an exact API replacement and does not guarantee that sites cannot detect automation.

**Can it bypass Cloudflare?**
The optional `cf_verify` helper attempts supported Cloudflare checkbox challenges. Success is not guaranteed, and it does not solve image or text CAPTCHA puzzles.

**I already use `chrome-devtools-mcp` or `playwright-mcp` and keep getting blocked. How do I switch?**
Add the server entry under [Manual config](#manual-config), then review the [tool reference](https://github.com/andresolbach/nodriver-mcp-server/blob/main/docs/TOOLS.md) and adapt any tool-specific prompts or scripts. You can run both servers side by side.

**The site works in my normal browser but not under automation. Why?**
Possible causes include profile or cookie differences, automation signals, IP reputation, request rates and application errors. Compare the network log and browser state before assuming the driver is the cause.

**Does it help with captchas?**
The optional `cf_verify` helper supports some Cloudflare checkbox challenges. Image grids, text CAPTCHAs and hCaptcha puzzles are not solved.

**Which clients are supported?**
One command installs it into 15+ MCP clients: Claude Desktop, Claude Code, Cursor, Windsurf, Codex, Gemini CLI, Copilot CLI, Kiro, VS Code, Cline, Roo Code, Amazon Q, Warp, Opencode, Trae.

**Can I run it in several clients at the same time?**
Yes. Each instance uses its own ephemeral Chrome profile by default, so Claude Desktop, Claude Code and the VS Code extension can all use nodriver **simultaneously** without colliding.

**Headless or visible browser?**
A real Chrome window by default; set `NODRIVER_HEADLESS=true` for headless.

**How do I keep a login between sessions?**
Create a persistent profile with `create_profile` and switch to it with `use_profile`, or use `save_session` / `load_session`.

**Does it work on Windows / macOS / Linux?**
The project supports all three. Browser integration tests run on Windows 11 with Chrome 155; CI runs the non-browser suite on Windows, macOS and Linux.

## Scope and limitations

- Detection resistance is site- and configuration-dependent. The fingerprint result above documents those checks in that environment; it does not guarantee access to every site. IP reputation, request rates and session state also matter.
- `cf_verify` is an optional helper for supported Cloudflare checkbox challenges, not a general CAPTCHA service. Image grids, text CAPTCHAs and hCaptcha puzzles are not solved.
- This is an independent tool API, not an exact replacement for `chrome-devtools-mcp`. Check the tool reference when migrating scripts or prompts.
- Persistent profiles can be used by only one running browser at a time. After a complete MCP server restart, select the same profile again with `use_profile` to resume it.

## Changelog

See [CHANGES.md](https://github.com/andresolbach/nodriver-mcp-server/blob/main/CHANGES.md). Version 2.6.0 adds per-profile startup modes, fixes graceful browser shutdown and tab restoration, and updates nodriver to 0.50.6. Existing network capture, passive security checks and isolated browser sessions remain available.

## Credits

Based on [`nodriver-mcp`](https://github.com/Saber-CC/nodriver-mcp) by **Saber-CC** (MIT). Browser backend by [`nodriver`](https://github.com/ultrafunkamsterdam/nodriver) (ultrafunkamsterdam). Some tool names and workflows are inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp).

## License

[MIT](https://github.com/andresolbach/nodriver-mcp-server/blob/main/LICENSE)

