invisible-playwright-mcp
Run a stealth, anti-bot-resistant Firefox browser as an MCP server and control it with browser automation tools.
Navigate to URLs and read page content as visible text, cleaned HTML, or a snapshot of visible interactive elements with reliable selectors or coordinates.
Interact with pages: click by selector, click at viewport coordinates with optional hold, type into fields, press keys, and evaluate JavaScript.
Take screenshots of the active tab.
Manage tabs: open a new page, list open pages, switch the active page, and close pages.
Configure a proxy, deterministic fingerprint seed, persistent profile, headless/headed mode, and HTTP transport so multiple clients can attach to the same browser.
Serve a live view and a lightweight chat shell over HTTP for watching and driving the same browser session.
Provides browser automation through a stealth-patched Firefox engine, enabling navigation, clicking, typing, reading page content, and taking screenshots on real websites.
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., "@invisible-playwright-mcp Go to news.ycombinator.com and return the top 3 stories"
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.
invisible-playwright-mcp
A stealth Firefox as an MCP server. Add it to Claude Code, Claude Desktop, Codex, Cursor or any other MCP client, and your assistant gets a real browser: tabs, navigation, reading, clicking, typing, dropdowns, keys, screenshots, a live view of the window, and a JavaScript reader, on a Firefox whose fingerprint is set inside the engine rather than bolted onto the page.
The engine is invisible-playwright,
a Firefox patched at the C++ source. This package is the MCP layer over it:
every tool below is a thin wrapper, and there is no interface here. The
interface, when you want one, is AIHawk.
How to install this, and the two ways to use it, are in AIHawk's README. This page keeps what the server itself owns: the config block for clients that take a file, the settings, and the tools.
Adding it to your client
Claude Code, Codex and Gemini CLI have a command for it, and the command is in
AIHawk's README. The rest take a config file, and the file is not the same
everywhere: three different top-level keys, and one of them is not even
JSON. Find yours below. The block only tells the client how to start the
server; installing uv and fetching the engine come first, as AIHawk's README
shows.
If your client takes a config file
Most use a top-level mcpServers - Claude Desktop, Cursor, Windsurf, Cline:
{
"mcpServers": {
"stealth": {
"command": "uvx",
"args": ["invisible-playwright-mcp"]
}
}
}Client | File |
Claude Desktop (macOS) |
|
Claude Desktop (Windows) |
|
Cursor |
|
Windsurf |
|
Cline |
|
Zed calls the key context_servers, not mcpServers, in
~/.config/zed/settings.json (%APPDATA%\Zed\settings.json on Windows):
{
"context_servers": {
"stealth": {
"command": "uvx",
"args": ["invisible-playwright-mcp"]
}
}
}VS Code calls it servers, in .vscode/mcp.json for a workspace:
{
"servers": {
"stealth": {
"type": "stdio",
"command": "uvx",
"args": ["invisible-playwright-mcp"]
}
}
}Codex uses TOML, in ~/.codex/config.toml:
[mcp_servers.stealth]
command = "uvx"
args = ["invisible-playwright-mcp"]Continue uses YAML with its own block format, which changed recently enough that we would rather point you at their documentation than print a block here that may already be stale.
Where a proxy and the other settings go
Everything in Settings below goes under env on the server entry, in
whatever shape your client uses:
{
"mcpServers": {
"stealth": {
"command": "uvx",
"args": ["invisible-playwright-mcp"],
"env": {
"STEALTHFOX_PROXY": "http://user:pass@proxy.example.com:8080",
"STEALTHFOX_SEED": "4242"
}
}
}
}In Codex's TOML that is a [mcp_servers.stealth.env] table; on the command line,
Claude Code and Codex take -e KEY=value and --env KEY=value.
⛔ "Added" is not "connected". Every one of these writes a config entry
without running anything, so a typo, a missing uv, or the first-run browser
download all surface later as a server that will not start. Check before you
trust it: claude mcp list, codex mcp list, or your client's MCP panel.
Related MCP server: Local Browser MCP
Settings
Environment variables, all optional. A proxy is the one worth adding: without it the exit IP, timezone and locale are your own machine's, which is a real gap between what the browser says it is and where it appears to be.
Variable | Meaning |
| Proxy URL, e.g. |
|
|
| Integer seed for a deterministic fingerprint (same seed, same identity). A profile's own seed wins over this one. |
| A directory for a persistent profile, so logins survive across runs. |
| Path to an engine binary you already have. It must be the build the packaged seal pins, or startup refuses. |
|
|
|
|
| Bind address for the HTTP transport. Default |
| Port for the HTTP transport. Default |
Anything a tool call says wins over these. session_start can pick another
seed, another exit or another profile for one session; the variables are what a
session gets when nobody says anything.
Tools
session_status, session_start, session_new_page, session_list_pages,
session_select_page, session_close_page, browser_navigate,
browser_read_text, browser_snapshot, browser_read_html,
browser_take_screenshot, browser_watch, browser_click, browser_click_at,
browser_type, browser_select_option, browser_press_key, browser_evaluate.
Tool names mirror the Microsoft Playwright MCP, so prompts written for it work here too. Three groups: who is browsing and which tab, reading the page, and acting on it.
Session and tabs
Tool | Arguments | What it does |
| none | Who is browsing right now: the seed, the exit, the profile and the open tabs. Starts nothing; if no browser is up it says so. |
|
| Close whatever is open and start a browser as a particular person. Returns a sentence describing the session it actually started. |
| none | Open a tab, make it the active one, return its id. |
| none | Every open tab: id, title, url, and which one is active. |
|
| Make a tab the active one. Every |
|
| Close a tab, or the active one when the id is left out. |
You can ignore session_start entirely: the first tool that needs a page starts
a session on its own, as a different stranger every time, which is the right
default. There is one browser, so two identities are visited in turn, never at
once; a task that needs two accounts live at the same time cannot be done here.
seedis the identity. Same seed, same fingerprint, every time. Leave it out and one is drawn; the answer says which, so a session worth repeating can be repeated.profileis a directory that keeps cookies and logins between sessions. A profile also owns its seed: the first session on a new one stores the identity inside it and every later session reuses it, so a login never comes back wearing different hardware. Ask for a seed that contradicts the one a profile carries and you get a refusal naming both numbers, never a silent choice. A relative path is resolved against the server's own directory, and the answer reports the full path it used.proxyis where the traffic leaves,http://user:pass@host:portorsocks5://host:port. Timezone, locale and geography follow it.Pass
""forprofileorproxyto insist on none, even when the environment sets a default. That is how you get sessions a site cannot link to each other.
A profile does not own its exit the way it owns its seed. The same login arriving from another country is as visible as one arriving on different hardware. You are warned when a profile's exit changes, but only when you change it: a provider rotating its own addresses behind one host and port is indistinguishable from here.
A session_start that fails, usually because the proxy is down, leaves nothing
running, and every later tool repeats the refusal until a session_start works.
It does not quietly start a browser without the exit that was asked for.
Reading the page
Tool | Arguments | What it returns |
|
| Goes to the url in the active tab, opening one if none exists. |
|
| The visible text of an element, markup gone. The cheapest way to read a page. Long text is cut at |
|
| Title, url, and the interactive elements that are actually visible, each with a |
|
| The page's HTML reduced to what is worth reading: |
| none | A screenshot of the active tab, as an image. |
| none | The whole browser window as a person at the machine sees it: tab strip, address bar, page and the pointer, from a live capture the session keeps running on the active tab. |
The selectors a snapshot hands out are built to match exactly one element, and that is the reason to pass them verbatim rather than writing your own: measured across 958 elements on real pages, 88% could be addressed by a selector but only 48% unambiguously, and Playwright acts on the first match, so a caller aiming at the third of five identical links would silently hit the first.
browser_watch is for the person watching, not for the model acting. The
pointer is drawn in the browser chrome on purpose, so that no page can see it,
which is also why no page screenshot can ever contain it. The picture is window
pixels: feed browser_take_screenshot to browser_click_at, not this. It needs
an engine from firefox-28 on; an older engine answers with a sentence saying
so.
Acting on the page
Tool | Arguments | What it does |
|
| Clicks the first element matching a CSS selector, scrolling it into view and waiting for it to be clickable. The pointer approaches, hovers, presses and releases, the way a hand does. |
|
| Clicks a viewport coordinate instead of a selector: moves the pointer there, presses, holds if asked, releases, and returns a screenshot taken right after. For a slider track, a canvas-drawn challenge, a precise point inside a wider element. |
|
| Fills a field, replacing whatever it holds. It sets the value rather than typing key by key, so per-keystroke handlers such as an autocomplete do not fire; for those, click the field and use |
|
| Chooses an option in a |
|
| Presses a key on whatever has focus: |
|
| Runs JavaScript to read from the page and returns the result as JSON: a computed style, a value held in a framework's state, the length of a list. |
browser_click_at takes coordinates relative to the viewport, not to the
page, so the ones in a snapshot go stale the moment anything scrolls: a click, a
keypress, a lazy image loading above the fold. Nothing raises when that happens;
the click lands on whatever is at that spot now. Take a fresh snapshot after
anything that could have moved the page, and prefer browser_click with an
element's selector whenever it has one.
browser_evaluate reads; it will not act. Assigning to value, checked or
selected, or calling click(), dispatchEvent(), submit() or
requestSubmit(), is refused, and the refusal names the tool to use instead.
Script reaches the page with no keystroke and no pointer, so the event carries
isTrusted false, which is the clearest signal a page can collect that nobody
is really there. Reading those properties is fine. The refusal catches the
obvious spellings, not every possible one; a script that slips past it is still
the wrong way to do the thing.
The order to try them in
The server hands every client this ladder, because a model that cannot find a way down it invents one:
A named tool with a selector:
browser_click,browser_type,browser_select_option,browser_press_key.browser_snapshotsupplies the selector.Coordinates: the snapshot reports
at: [x, y]for every element, andbrowser_click_atmoves the pointer there. For a canvas, a slider, a map, a widget built out of divs.A screenshot:
browser_take_screenshot, thenbrowser_click_aton what you can see. For what the snapshot does not list at all.browser_evaluate, to read what none of the above can see.
Getting to the bottom of the ladder without a way to do the thing is a result too: a task reported as impossible is worth more than one completed in a way that gets the session blocked.
Why each tool returns what it does, with the measurements behind it: docs/tool-design.md.
More than one client on the same browser
Over stdio the browser belongs to the client that opened it. Set
STEALTHFOX_MCP_TRANSPORT=http and it does not: the session is owned by the
server, so a second client can attach to the browser the first one left open,
and closing a client no longer kills the browser.
STEALTHFOX_MCP_TRANSPORT=http uvx invisible-playwright-mcp # Linux$env:STEALTHFOX_MCP_TRANSPORT = "http"; uvx invisible-playwright-mcp # WindowsTo SEE the browser rather than share it, AIHawk shows the live page beside the conversation.
Notes
This is a browser, not a captcha solver. It does not solve or bypass challenges for you; it makes an ordinary Firefox session look like a real one.
One browser per server process. Tabs are the way to keep several pages open; two identities at once need two servers.
License
MIT, the same as the engine it wraps.
Available Tools
18 toolsbrowser_clickA
Click the first element matching a CSS selector.
Scrolls it into view and waits for it to be clickable. When no selector can describe the target, use browser_click_at with coordinates from browser_snapshot.
Inputs: selector (required). Returns: result
browser_click_atA
Click (or press-and-hold) a raw viewport coordinate instead of a selector - for targets a selector cannot reliably reach: a slider track, a canvas-drawn captcha, or a precise point inside a wider element. Moves the pointer there first (no teleport), then down, then up, holding first if hold_seconds is set. Returns a screenshot taken right after release.
hold_seconds needs invisible-playwright 0.9.0 or newer to mean anything. In every earlier version the wait it is built on returned instantly, so the press and the release happened in the same frame and the hold never happened - on the one tool that exists for sliders and press-and-hold challenges. The floor in pyproject.toml is set accordingly.
Coordinates are relative to the VIEWPORT, not to the page, so the ones in a
snapshot go stale the moment anything scrolls: a click, a keypress, a lazy
image loading in above the fold. Nothing raises when that happens - the
click simply lands on whatever is at that spot now. Take a fresh snapshot
after anything that could have moved the page, and prefer browser_click with
the element's selector whenever it has one.
Inputs: x (required), y (required), hold_seconds.
browser_evaluateA
READ from the page with JavaScript and get the result as JSON.
For what the other tools cannot see: a computed style, a value held in a framework's state, the length of a list.
Acting on the page is refused, and the refusal names the tool to use.
Assigning to value, checked or selected, or calling click(),
dispatchEvent(), submit() or requestSubmit(), changes the page without
a real keystroke or pointer, and a page can tell. Use browser_click,
browser_type or browser_select_option instead; they do the same thing
through the pointer and the keyboard. Reading any of those properties is
fine.
The refusal catches the obvious spellings, not every possible one. A script that slips past it is still the wrong way to do the thing: report it in your answer rather than using it.
Inputs: expression (required). Returns: result
browser_press_keyA
Press a key on whatever has focus: "Enter", "Tab", "Escape", "ArrowDown", "Control+a", or a single character.
Inputs: key (required). Returns: result
browser_read_htmlA
The page's HTML, cleaned down to what is worth reading.
Use this when the STRUCTURE matters - a form and its labels, a table, what
a control is wired to. browser_snapshot gives a flat inventory of things
to click; this keeps the markup and the relationships inside it.
mode="form" keeps the interactive surface and the text explaining it, mode="text" returns the prose alone, mode="full" keeps the structure with the noise and the attribute soup removed.
Unlike browser_read_text this is NOT capped: it returns the whole reduced page, which on a large one is tens of thousands of characters. That is deliberate, because cutting markup in the middle leaves tags that no longer mean anything - but it means the answer can be long. Reach for browser_snapshot when you only need something to click, or browser_read_text when you only need the words.
Inputs: mode. Returns: result
browser_read_textA
The visible text of an element, with the markup gone.
The cheapest way to read a page. Narrow the selector when you know where the answer is; use browser_read_html instead when the structure matters, or browser_snapshot when you need something to click.
Long text is cut at max_chars (6000 by default) and the cut is marked in what comes back, so text that ends without that marker is the whole thing.
Inputs: selector, max_chars. Returns: result
browser_select_optionA
Choose an option in a dropdown (<select>), by its visible label or by
its value.
Use this rather than clicking the dropdown and pressing arrow keys: a click plus arrows cannot tell you which row it landed on, and setting the value through browser_evaluate changes it without the page seeing a real interaction.
Inputs: value (required), selector (required). Returns: result
browser_snapshotA
Title, url, and the interactive elements that are actually visible.
Each element carries a selector when one can reach it: pass that string to
browser_click or browser_type VERBATIM. It is built to match exactly one
element, which the obvious selector often does not - measured across 958
elements on real pages, 88% could be addressed but only 48% unambiguously,
and Playwright acts on the first match, so a caller aiming at the third of
five identical links would silently hit the first.
Elements with no selector carry at, the centre coordinates, for
browser_click_at.
Not the accessibility tree: on a real sign-up page a single country
<select> contributes about two hundred <option> nodes, which fill the
character cap before the form the caller was looking for appears at all.
Inputs: max_chars. Returns: result
browser_take_screenshotA
One screenshot of the active tab, on demand.
Inputs: no named parameters.
browser_typeA
Fill a field, replacing whatever it holds.
This sets the value rather than typing key by key, so it will not fire the per-keystroke handlers an autocomplete needs. For those, click the field and use browser_press_key.
Inputs: text (required), selector (required). Returns: result
browser_watchA
The whole browser window as a person at the machine sees it: tab strip, address bar, the page and the pointer, from a live capture kept running on the active tab. For watching the work, not for acting on it: the picture is window pixels, so do not feed its coordinates to browser_click_at; use browser_take_screenshot for that.
Inputs: no named parameters.
session_close_pageA
Close a tab, or the active one when page_id is left out.
Inputs: page_id. Returns: result
session_list_pagesA
Every open tab: id, title, url, and which one is active.
Use it before session_select_page: the id alone does not tell you which tab you are switching to.
Inputs: no named parameters. Returns: result
session_new_pageA
Open a new tab and make it the active one. Returns its page id.
Tabs persist across calls and across clients, so this is how you keep one page while working on another rather than navigating back and forth.
Inputs: no named parameters. Returns: result
session_select_pageA
Switch the active tab. Every other browser_* tool acts on it.
Take the id from session_list_pages or from session_new_page.
Inputs: page_id (required). Returns: result
session_startA
Start a browsing session as a particular person, and say who that is.
Call this when you want to control WHO is browsing: a fresh stranger, the same person as last time, or a saved profile that is already logged in somewhere. Calling it closes whatever browser is open and starts another, so anything not saved in a profile is gone.
You do not have to call it at all. The first tool that needs a page starts a
session on its own; session_status then tells you who that turned out to
be.
There is only ONE browser. Two identities are visited in turn, never at the same time, so a task that needs both accounts live at once cannot be done here and is worth saying so rather than half-starting.
seed the browser identity. Same seed, same fingerprint, every time.
Leave it out and one is drawn, and the answer tells you which, so
you can ask for it again later.
profile a directory that keeps cookies and logins between sessions. A
profile also KEEPS ITS SEED: the first session on a new one stores
the identity inside it, and every session after reuses it, so a
login does not come back wearing different hardware. Pass "" to
insist on no profile at all, which is how you get sessions a site
cannot link to each other. A relative path is resolved against the
server's own directory, so the answer reports the full path it
used.
proxy where the traffic goes out, as http://user:pass@host:port or
socks5://host:port. Pass "" to insist on going out from this
machine's own address. A profile does NOT pin its exit the way it
pins its seed: timezone, locale and geography come from the exit,
so the same login arriving from another country is as visible as
one arriving on different hardware. You are warned when a profile's
exit changes, but only when YOU change it - a provider that rotates
its own addresses behind one host and port looks identical here.
Inputs: seed, proxy, profile. Returns: result
session_statusA
Who is browsing right now: the identity, the exit, the profile and the tabs.
Ask whenever you need to know which person the browser currently is, or from
where its traffic leaves. The seed is what you would pass to session_start
to become this person again, so this is also how you record a session that
is worth repeating.
It starts nothing. If no browser is running yet it says so, because until one is running there is no identity to report.
Inputs: no named parameters. Returns: result
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 18 tools
Each tool targets a distinct operation: session lifecycle, tab management, navigation, reading modes, and input actions are cleanly separated. Even the overlapping read tools (text, HTML, snapshot) explicitly describe when to use each, so an agent can select them without ambiguity.
The set consistently uses snake_case with `session_*` and `browser_*` prefixes and mostly action+object names like `browser_select_option` and `session_close_page`. Minor noun-style exceptions such as `session_status` and `browser_snapshot` are small deviations but do not break the overall pattern.
18 tools is slightly above the typical 3-15 sweet spot, but for browser automation each tool earns its place by covering a distinct capability. There is little redundant filler, so the count feels reasonable rather than bloated.
The surface covers session/identity management, tab lifecycle, navigation, multiple read modes, and the common input actions well. Obvious gaps are minor—no explicit wait-for-selector, hover, upload, or back/forward—but agents can usually work around them using navigate, wait_until, click, or press_key.
Maintenance
Related MCP Connectors
Stealth web browser for agents: search, fetch, click, download and type in persistent MCP sessions.
Turn any public website into an MCP server for agents to search, read and navigate.
Web scraping, browser automation, screenshots and AI data extraction for AI agents via MCP.
Official Browserless MCP server: browser automation, scraping, screenshots and PDFs for AI agents.
Related MCP Servers
- AlicenseBqualityDmaintenanceBrowser MCP server that connects to your existing browser, preserving sessions, passwords, and extensions, enabling AI agents to interact with web pages without bot detection.313 npm1MIT
- AlicenseBqualityBmaintenanceA headless, agent-controllable real browser as an MCP server that enables AI agents to navigate, click, fill, eval JavaScript, and take screenshots on localhost and allowed hosts, with no GUI required.277MIT
- AlicenseNot gradedqualityDmaintenanceA stealth-enhanced browser automation MCP server for AI agents to interact with websites while bypassing anti-bot detection mechanisms like Cloudflare and reCAPTCHA.9MIT
- AlicenseNot gradedqualityBmaintenanceThis MCP server exposes an anti-detect Firefox browser that passes bot-detection tests, allowing LLMs to automate web interactions with humanized clicks and fingerprint randomization.MIT