The missing piece in AI coding: your agent can now see your REAL browser.
You ship a fix. Your agent says "done, please verify." You alt-tab to Chrome, navigate to the page, log in, click around, find the bug.
Your agent just wrote the code. It could also verify it. It already has your browser open right there. It just can't see it.
Now it can. Browser Controller gives any MCP-compatible AI agent (Cursor, Claude Desktop, Windsurf, …) direct control of the browser you already have open — your real sessions, your logins, your cookies. No headless browser, no fresh profile, no re-authentication.
- Multiple agents at once. Cursor can drive tab 10 while Claude drives tab 11 — both through one shared daemon, neither blocking the other.
- Tab targeting, not "the active tab." Every action names a
tabId. Move your mouse, switch tabs, watch YouTube — the agent keeps working on the tab you told it to. It never hijacks the page you're reading. - Per-tab isolation. Element refs, console logs, and network buffers are scoped per tab. A ref from tab 10 can never click something in tab 20.
- Per-tab concurrency. Two actions on the same tab serialize (no races); actions on different tabs run in parallel.
- Tab locking. An agent can claim a tab so others queue behind it instead of racing (
browser_tabs { action: "lock" }). Locks survive Chrome's service-worker recycling (chrome.storage.session). - Agent-control shield. While an agent works on a tab you see a translucent blue inner frame and your input on that tab is blocked (mouse, keyboard, wheel) — the badge shows
agent <name> controlling the taband disappears when the action finishes. Locking a tab keeps a plain frame for the lock's lifetime. - Visible agent cursor (optional). Switch on Show agent cursor in the popup's Settings and every agent mouse action (click, hover, wheel, drag — including
browser_act) draws a blue pointer that glides to its target before the input lands — a ripple marks each click, the arrow presses in while dragging — so you can follow what the agent does. Off by default: the glide adds ~0.2–0.35 s to each mouse action on a visible tab (none in hidden tabs). It fades after a few idle seconds and stays out of the agent's own screenshots. - Same-origin iframe piercing. Legacy/enterprise UIs that live inside iframes (e.g. an ONT console in
iframe#mainFrame) are reachable: all locator tools search iframe documents, andfind/click_textwalk every frame. - Open-dialog rescue. A native
alert/confirm/promptfreezes the page's JS thread —browser_handle_dialogdismisses it out-of-band via CDP, no page JS needed, which also un-blocks every other tool on that tab.browser_tabs close/focusalways work, even on a frozen tab. - Authenticated local connection. Token + enrollment secret, so no other local process can silently drive your browser — and after a one-time
npm run setup:pairingthe extension pairs itself, no secret to paste. Everything stays on localhost — no cloud, no telemetry. - Versioned compatibility handshake. The daemon and extension advertise an application-protocol version, build version, and capabilities before tool traffic is accepted. Explicitly incompatible protocol majors fail with an actionable error instead of producing unexplained timeouts; the build version is diagnostic and does not by itself make compatible peers fail.
- Real, trusted input. Clicks, typing and key presses go through the Chrome DevTools Protocol, so the page sees
isTrustedevents, focus really moves, default actions run (Tab moves focus, Enter submits, arrows drive autocomplete menus) and focus/blur fire even while the window is in the background — legacy grids and lookup widgets behave as they do for a person. One debugger session per tab is reused and detached after 30 s idle (the yellow "being debugged" banner shows only while it is attached). Passtrusted: falsefor the old synthetic events with no banner; they are also the automatic fallback when the debugger can't attach. - Batches.
browser_batchruns a list of tool calls in one round-trip and stops at the first failure — a click → type → Tab → wait → read sequence is one call instead of five. - WebMCP-aware. On sites that expose their own actions through WebMCP,
browser_webmcp_list/browser_webmcp_callcall them directly instead of clicking through the UI — with confirmation gates for consequential tools and page output flagged as untrusted. - Console-style JavaScript.
browser_evaluateaccepts code as you'd type it in DevTools: top-levelawait, several statements, the last expression's value is returned, DOM nodes come back as readable descriptions — and page CSP doesn't block it. - Refs that stay right. Snapshot/find refs resolve through one shared page runtime: the registered element first, then the first visible selector match (across open and closed shadow roots and same-origin iframes), then a verified fallback that only re-binds when role, tag and name identify one element — an ambiguous match is reported as gone instead of clicked. Hidden duplicates are skipped.
- Native Chrome accessibility tree.
browser_snapshot { source: "native" }reads the tree Chrome itself computes (CDPAccessibility.getFullAXTree): exact roles, accessible names (aria-labelledby,<label>, native widget semantics), states (checked,expanded,invalid, headinglevel…) andaria-hidden/inertexclusion — what a screen reader sees. Its refs work with every ref tool, including inside closed shadow roots and same-origin iframes. Opt-in; the defaultsource: "dom"needs no debugger. - Shadow DOM everywhere.
snapshot,text,find,click_text,waitand every locator see web components (open and closed roots, slots), so sites like caniuse read like any other page. - Frozen tabs don't freeze the agent. Every page call has an 8 s budget; a tab that stops answering is reported as
TAB_WEDGEDin seconds, later calls fail fast after a 1.5 s probe, andbrowser_navigate/browser_tabs reloadreplace the frozen tab in place (the result carries the newtabId). - Coordinates when you need them. Click, hover and wheel-scroll at
x/y, triple-click, ctrl/shift-click, key sequences withrepeat, and zoomedregionscreenshots that tell you how image pixels map to those coordinates. - Record and replay.
browser_gifrecords what the agent does in a tab as an animated GIF you can show to a person — a frame after every page-changing action, andexportwrites the file and returns its path;browser_shortcutssaves a flow with{{variables}}and replays it in one call. - Several browsers. Every connected Chrome profile is its own connection;
browser_list_browsers/browser_select_browserpick one per session (one browser behaves exactly as before). - Honest errors. Every tool failure reaches your agent as a real
isErrorresult with the full payload — no "success" responses hiding failures mid-workflow.
Three pieces, all on your machine. Nothing leaves localhost. The daemon control plane is hard-bound to 127.0.0.1; there is no supported remote/n8n listener.
flowchart LR
subgraph Client["MCP client (Cursor / Claude / Windsurf)"]
A["agent"]
end
subgraph Thin["thin MCP client — mcp-server/dist/index.js"]
B["DaemonClient<br/>speaks MCP over stdio<br/>spawns daemon if not running<br/>gets its own sessionId"]
end
subgraph Daemon["daemon — single long-running process, owns port 7225"]
C["daemon.ts<br/>multiplexes N clients → 1 extension<br/>sessions · rate limit · heartbeats"]
D["bridge.ts (ExtensionBridge)<br/>HTTP /pair /status /kill<br/>WebSocket for the extension"]
end
subgraph Ext["Chrome extension (Manifest V3 service worker)"]
E["connection.js<br/>pairing · reconnect backoff"]
F["router.js<br/>tool dispatch"]
G["tab-concurrency.js<br/>per-tab mutex · locks · shield"]
H["handlers/*<br/>interaction · inspection · agent-api<br/>cdp · navigation · tabs"]
I["page-exec.js safeExec<br/>chrome.scripting func: — CSP-safe"]
E --> F --> G --> H --> I
end
P["Page — your real sessions"]
A -->|stdio MCP| B
B -->|IPC socket + token| C
C --> D
D <-->|Authenticated WebSocket<br/>protocol version + capabilities| E
I --> P
Key idea: the first time any agent runs, the thin client spawns a background daemon that owns port 7225 and the extension connection. Every subsequent agent (even from a different MCP client) connects to that same daemon over a local IPC socket and gets its own sessionId. The extension sees one stable connection and routes each call to the exact tab the caller specified.
The project is not on the Chrome Web Store or npm — you install it from this repository. Two parts: the MCP server (runs on your machine, talks to your AI agent) and the Chrome extension (sits in your browser, executes commands).
Prerequisites: Node.js ≥ 20 and Chrome/Chromium/Edge.
git clone https://github.com/compnew2006/browser-controller.git
cd browser-controller
npm install
npm run build # compiles TypeScript → mcp-server/dist/- Open
chrome://extensionsand enable Developer mode (toggle in the top right) - Click Load unpacked and select the
extension/folder from the cloned repo - Pin the Browser Controller icon to your toolbar
Gray dot = waiting for the daemon. Green = connected.
Cursor: Settings → MCP → "Add new MCP server". Claude Desktop: edit claude_desktop_config.json. Windsurf: Settings → MCP. Any MCP-compatible client works.
Replace /path/to/browser-controller with the absolute path of your clone (Windows: use C:\\path\\to\\browser-controller\\mcp-server\\dist\\index.js):
{
"mcpServers": {
"browser-controller": {
"command": "node",
"args": ["/path/to/browser-controller/mcp-server/dist/index.js"]
}
}
}Name your agent (shows in the popup)
By default the daemon names each connection after its parent IDE ("Cursor", "Claude", …). To override — e.g. when several agents share one IDE, or to label them by project — pass --agent <name> in the args. It takes priority over every auto-detection:
{
"mcpServers": {
"browser-controller": {
"command": "node",
"args": [
"/path/to/browser-controller/mcp-server/dist/index.js",
"--agent",
"My Project Agent"
]
}
}
}The name appears in the popup's Connected Agents list. (You can also set the MCP_AGENT_NAME env var — equivalent.) Reconnecting with the same name replaces the old entry, so IDE restarts don't pile up duplicates.
Run this once from the repo root:
npm run setup:pairingThen click Reload on Browser Controller in chrome://extensions. The extension pairs itself — nothing to copy or paste. Green dot = you're connected. Your agent can now see your browser.
What the command sets up:
- A small local helper (a Chrome native messaging host,
mcp-server/dist/native-host.js), registered with every Chromium browser it finds: Chrome (stable, Beta, Canary), Chromium, Edge and Brave. It is a per-user install (on Windows, underHKCU\Software\…\NativeMessagingHosts) and needs no admin rights. - Chrome lets only this extension's ID start the helper. An unpacked extension's ID comes from its folder path; the command prints the ID it computed, and it must match the one shown in
chrome://extensions. If you loaded the extension from another folder, pass its ID:npm run setup:pairing -- --extension-id <id>. - A launcher in
.native-host/that pins the currentnode, because a browser started from the Dock or Start menu does not see your shell'sPATH.
The extension asks the helper only when the daemon refuses its secret — the first time, and after you rotate the secrets — so a rotation needs no action either. Check the setup with npm run pairing:status (it never prints the secret) and remove it with npm run pairing:uninstall. Run npm run setup:pairing again after moving the repository or switching Node installations, since the launcher pins both paths. When pairing cannot happen, the popup's activity log says why and what to run.
Pair by hand instead
Only the enrollment secret is needed — the extension fetches the auth token from the daemon by itself.
- Start the daemon once by asking your agent to "list my browser tabs". It creates the secrets in
~/.browser-controller/(Windows:%USERPROFILE%\.browser-controller\), and the MCP client prints the enrollment secret to its log on first run. - Copy the
secretvalue:cat ~/.browser-controller/enrollment.json - Click the extension icon → Settings tab → paste it into Enrollment Secret (leave the port at
7225unless you changedWS_PORT).
These secrets prevent any other local process from opening a WebSocket and driving your authenticated browser sessions. To rotate them, stop your MCP clients, delete the folder, and the next run recreates both secrets. See SECURITY.md for the full threat model, including why the pairing helper does not weaken it.
The daemon is the only process that owns the extension-facing runtime. MCP
clients are thin stdio adapters and may start it automatically, but deployment
scripts should use the lifecycle commands below so there is one restart owner.
Do not run a second daemon.js or install a launch supervisor that competes for
port 7225.
npm run build
npm run daemon:start # start, or report the already-running PID
npm run daemon:status # JSON health/runtime information
npm run daemon:stop # graceful SIGTERM; removes runtime metadata
npm run daemon:restart # stop, then start; preserves token/enrollmentThe daemon survives browser/Chrome restarts: token, enrollment secret, and
pairing remain in ~/.browser-controller/ (override with BC_STATE_DIR), and
the extension reconnects to the same endpoint. The lifecycle wrapper reads the
daemon.json PID and is safe to run repeatedly; a stale lock is removed only
when its recorded PID is not alive.
- MCP endpoint: stdio, launched as
node mcp-server/dist/index.js(the standardmcpServerscommand/args form is shown above). - Extension runtime:
ws://127.0.0.1:7225by default, with HTTP/pair,/status, and/kill?sessionId=...on the same port. These are daemon/popup endpoints, not an MCP HTTP transport. - MCP client IPC:
~/.browser-controller/daemon.sockon Unix or\\.\pipe\browser-controlleron Windows. It is internal and token-authenticated. - State:
~/.browser-controller/{daemon.json,daemon.lock,token.json, enrollment.json,daemon.log}.WS_PORT,WS_HOST, andBC_STATE_DIRare the supported configuration overrides.
This preserves the real Chrome session workflow: no Playwright/headless browser is launched, and the existing Chrome profile, cookies, logins, and tabs remain the browser being controlled.
The unpacked extension deliberately requests Chrome's powerful debugger, scripting, webRequest, and <all_urls> permissions, plus nativeMessaging, which it uses only to ask the local pairing helper for the enrollment secret. They are what let it inspect network activity, inject functions, upload files through CDP, and automate any normal web tab you select. They also mean a connected MCP agent can read and change sensitive pages in your signed-in browser. Install the extension only from source you trust, pair it only with a trusted local daemon, and do not expose the daemon port beyond localhost. Chrome-protected pages such as chrome://, the Web Store, and DevTools remain inaccessible.
The model is tab-first: the agent always says which tab to act on. It never assumes "the active tab."
- List tabs to get a
tabId:browser_tabs { action: "list" } → [{ id: 15, url: "...", title: "...", active: true, lockedBy: null }, ...] - Snapshot that tab to see its structure and get element refs:
Refs are valid only for this tabId. If you navigate or the DOM changes, re-snapshot. New elements since the last snapshot are tagged
browser_snapshot { tabId: 15 } → { tree: [ { ref: "e3", role: "button", name: "Sign in" }, ... ] }isNew: true— after an action opens an overlay/dropdown, the agent can focus on just those instead of re-reading the whole tree. - Interact using the ref and the same tabId:
If a ref is stale but the element still exists, it's found automatically via a robust selector + text/role scan (response carries
browser_click { tabId: 15, ref: "e3" } browser_type { tabId: 15, ref: "e5", text: "hello@example.com" } browser_press_key { tabId: 15, key: "Enter" }via: "fallback"). If the element was scrolled away entirely (virtualized feeds), the response carriesfreshRefs: [...]with a fresh snapshot inline — retry with one of those new refs in the same step, no separate snapshot needed. - Verify — snapshot or read text again after the action.
browser_snapshot has two sources. Both return the same shape (ref, role, name, value, state flags, href, children) and their refs are interchangeable with every ref tool.
source: "dom" (default) |
source: "native" |
|
|---|---|---|
| Built from | A walk of the DOM with ARIA rules re-implemented in the extension | Chrome's accessibility engine, via CDP Accessibility.getFullAXTree |
| Roles / names / states | Approximation (explicit role, tag map, aria-label, <label>, text) |
Exactly what assistive technology gets: name computation, native widget roles, checked / expanded / invalid / heading level, aria-hidden and inert honoured |
| Debugger | Not used (no banner) | Attached (yellow "being debugged" banner, same as trusted input) |
| Cost | ~tens of ms on typical pages | Same, plus Chrome's tree computation: roughly 2 s per 40k accessibility nodes on a very large page |
Custom clickable <div tabindex> |
Listed | Listed only when it has an accessible name (Chrome calls it generic) |
| If unavailable | — | Falls back to the DOM tree and adds nativeUnavailable: "<reason>" |
browser_snapshot { tabId: 15, source: "native" }
→ { source: "native", tree: [ { ref: "s4k2-3", role: "textbox", name: "Search query", value: "abc", required: true }, … ] }
browser_snapshot { tabId: 15, source: "native", selector: "form", compact: false } // scoped, full tree incl. text
How refs are bound: every accessibility node carries a backendDOMNodeId; the extension resolves it to its element (closed shadow roots and same-origin iframes included) and registers it in the same page-side ref registry the DOM snapshot uses — no attribute or other mutation of the page. Because of that, click / type / hover / select / scroll / drag / fill_form act on the exact element (two buttons both named "Save" stay distinct) and keep the stale-ref fallback and isNew behaviour. Limits: at most 1500 refs per snapshot (refLimited: true when hit); cross-origin (out-of-process) iframes are reported in skippedFrames; controls with no page element to act on — Chrome-internal parts such as the sub-fields and picker button inside a date input, or an element that vanished mid-snapshot — are left out of the compact tree and listed without a ref in the full tree, counted in unreachableNodes.
For automation that must fail safely when a page changes, use the Browser Controller 2.0 agent API. browser_observe captures one compact semantic state in a single page execution and returns session-, tab-, document-, and snapshot-owned refs:
browser_observe { tabId: 15, mode: "compact" }
→ {
snapshotId: "s_…",
documentVersion: "d_…:1:0",
elements: [
{ ref: "e1", role: "button", name: "Continue",
bbox: [422, 680, 160, 44], allowedActions: ["click", "focus", "hover"] }
]
}
Pass the same tabId, snapshotId, and ref to browser_act:
browser_act { tabId: 15, snapshotId: "s_…", action: "click", ref: "e1" }
browser_act { tabId: 15, snapshotId: "s_…", action: "type", ref: "e4", text: "London", clear: true }
Before acting, the extension checks snapshot ownership, document/route identity, target semantics, current geometry and visibility, enabled state, allowed actions, and click-point occlusion. Recoverable replacements need one unique stable identity; ambiguous or unsafe state returns a compact error such as DOCUMENT_CHANGED, STALE_STATE, TARGET_OCCLUDED, or ACTION_NOT_ALLOWED. Call browser_observe again after one of these state errors.
Some sites expose their actions to agents through WebMCP. The page registers tools in JavaScript (document.modelContext.registerTool(...)) or marks a form with toolname. When a page offers a tool for what the agent needs — "add to cart", "filter by size", "create issue" — calling it is faster and more reliable than clicking through the UI. Everything else in this README still applies: pages without WebMCP are driven exactly as before.
Turn it on
- Use Chrome 149 or newer.
- Sites in Chrome's WebMCP origin trial work as they are.
- To test any page (including your own localhost app), enable
chrome://flags/#enable-webmcp-testingand relaunch Chrome.
- After updating Browser Controller, reload the extension in
chrome://extensionsand restart the daemon (npm run daemon:restart). - Nothing to configure in your MCP client:
browser_webmcp_listandbrowser_webmcp_callappear with the other tools. In progressive mode, find them withbrowser_tools { action: "search", query: "webmcp" }.
Workflow
browser_webmcp_list { tabId: 15 }
→ { supported: true, count: 2, untrustedContent: true, tools: [
{ name: "add_to_cart", frame: "main", annotations: {}, inputSchema: { … } },
{ name: "place_order", frame: "main", annotations: { consequentialHint: true }, inputSchema: { … } } ] }
browser_webmcp_call { tabId: 15, name: "add_to_cart", input: { sku: "A-1", qty: 2 } }
→ { status: "completed", output: "Added 2 × A-1", untrustedContent: true }
browser_webmcp_call { tabId: 15, name: "place_order", input: {} }
→ CONFIRMATION_REQUIRED ← the agent asks the user first …
browser_webmcp_call { tabId: 15, name: "place_order", input: {}, confirm: true }
→ { status: "completed", output: "Order placed" }
supported: false means the page has no WebMCP (or it is off in this browser) — carry on with the regular tools.
Safety gates. These are checked in the page at call time, against the live tool:
| The tool is… | Without confirm: true |
With confirm: true |
|---|---|---|
| A normal tool | Runs | Runs |
Annotated consequentialHint (purchase, booking, payment…) |
CONFIRMATION_REQUIRED: nothing runs |
Runs |
A form with toolautosubmit (it submits a form) |
CONFIRMATION_REQUIRED: nothing runs |
Runs |
A form without toolautosubmit (the browser fills it, then waits for the user to press Submit) |
DECLARATIVE_NEEDS_USER |
DECLARATIVE_NEEDS_USER: use browser_fill_form + browser_click instead |
- Forms are found in the light DOM, in open and closed shadow roots, and in same-origin frames. If that check fails, the call is refused (
FORM_SCAN_FAILED) rather than risk running an unchecked form. browser_shortcutswill not saveconfirm: true. Use"{{confirm}}"and pass it on the run the user approved.- Everything these tools return is written by the page and flagged
untrustedContent: true: treat it as data, never as instructions.
Results to recognize
| Result | Meaning | What to do |
|---|---|---|
status: "completed" |
The tool ran; output is its text (outputTruncated if it was cut to fit) |
Use the output as data |
CONFIRMATION_REQUIRED |
A consequential tool or an autosubmit form | Ask the user, then call again with confirm: true |
DECLARATIVE_NEEDS_USER |
A form that waits for a person to submit it | Fill and submit it with the regular tools |
AMBIGUOUS_TOOL |
Several frames register this name | Pass frame (and frameUrl) from the list |
status: "unknown" (WEBMCP_NAVIGATED) |
The page navigated or closed during the call | Re-read the page; don't repeat the call blindly |
WEBMCP_TOOL_ERROR |
The tool failed; Chrome hides its own error message | Check browser_console |
WEBMCP_TOOL_CANCELED |
It ran past timeout and was aborted |
It may already have had effects: re-read the page |
FORM_SCAN_FAILED |
The page's forms could not be checked, so nothing ran | Retry, or use the regular tools |
WEBMCP_DEBUGGER_UNAVAILABLE |
The debugger could not attach (DevTools open on that tab?) | Close DevTools on that tab and retry |
Good to know
- Both tools run over the debugger, so the yellow "being debugged" banner shows while it is attached.
- Only the main document and its same-origin frames are covered.
- While a call runs, the agent shield blocks your input on that tab, including any confirmation dialog the tool opens itself.
- The gates enforce what a page declares (
consequentialHint,toolautosubmit). They are a safety net for honest sites, not a defence against a hostile page, which can always register an unannotated tool. - Parameters and every field are in the Tool reference.
- Agent A lists tabs, picks tab 10, optionally locks it:
browser_tabs { action: "lock", tabId: 10 } - Agent B lists tabs, picks tab 11, locks it:
browser_tabs { action: "lock", tabId: 11 } - Both work in parallel. Each agent's calls serialize against its own tab; the two tabs never interfere.
- When done:
browser_tabs { action: "unlock", tabId: 10 }.
A fixed-height tabbed shell (the body never scrolls, only the lists do):
- Tabs — every open tab with its lock owner, plus Unlock all in the toolbar for one-click release if an agent crashed mid-lock.
- Agents — each connected agent with its name, session id, uptime, and a ✕ to disconnect it immediately (clears a zombie the heartbeat hasn't reaped yet).
- Settings — WebSocket port, Auth Token, Enrollment Secret.
- Activity bar — a collapsible strip at the bottom showing the latest tool activity; expand it for the rolling log.
- Forgot
tabId? You'll get a clear error:tabId is required. Call browser_tabs list first. - Protected pages (
chrome://, the Web Store, devtools) can't be scripted — you'll getCannot access protected page (chrome://...)instead of a silent hang. browser_navigateis the one tool wheretabIdis optional (defaults to the active tab) — but for multi-agent safety, pass it explicitly. Hash-only changes (e.g./page→/page#section) resolve as soon as the URL is set, without waiting for acompleteevent (SPAs don't reload on hash change, so that event never fires).browser_evaluateruns over CDP in REPL mode (top-levelawait, last expression returned,timeoutup to 120 s).mode: "scripting"runs it in the page's MAIN world viachrome.scriptinginstead — no debugger banner, but a strict page CSP can reject it and top-levelawaitisn't available. It andbrowser_run_actionare powerful and non-idempotent, so they are not auto-retried on timeout.browser_typetypes like a person:change/blur fire when focus leaves the field, so follow it withbrowser_press_key { key: "Tab" }to commit a value. It returns the field's value after typing.- Iframe reach is origin-bound. Snapshot, find, and interaction handlers can descend into same-origin iframes. Browser same-origin rules prevent those DOM paths from entering cross-origin iframes; use a separately targetable tab or an origin-specific integration for content inside them.
- The control shield is top-frame protection. Its frame and input interception are installed in the top document. Do not treat it as a security boundary for independently focused or cross-origin child frames; avoid manual input anywhere in a tab while an agent owns it.
- Scrolling virtualized feeds (Facebook/Instagram/Twitter):
browser_scrollreturnsrefsMayBeStale: truebecause those sites recycle DOM nodes. Re-snapshot before your next interaction. - Duplicate elements: when several elements share text+role (e.g. 3 "Like" buttons), the fallback resolver picks the correct one by ordinal (
nth), not just the first match. - A frozen tab (native dialog blocking) doesn't deadlock you:
browser_handle_dialogdismisses it via CDP, andbrowser_tabs { action: "close" }always works as the guaranteed way out.
The agent can use all 34 tools out of the box, but it works better when it knows the tab-first workflow. From the repo root:
npm run setup:cursor # or: node mcp-server/dist/index.js --setup cursorThis installs:
~/.cursor/rules/browser-controller.mdc— the tab-targeting workflow, dropdown handling, when to lock tabs~/.cursor/commands/check-browser.md— adds/check-browserto your Cursor chat
After that, type /check-browser in any chat. Or just say "check the result in my browser" and the agent knows what to do.
Claude Code setup
npm run setup:claudeAdds an AGENTS.md to your project root. Claude Code auto-discovers it.
See agent-config/ for manual installation or to customize the rules.
34 tools plus the browser_tools discovery tool. Every page-interaction tool takes a tabId (the one exception is browser_navigate, where it's optional). This section is the index — every tool is explained in the Tool reference below.
See
| Tool | What it does |
|---|---|
browser_observe |
Compact atomic semantic observation with snapshot/document identity, geometry, state, and dynamic allowed actions |
browser_snapshot |
Accessibility tree with element refs. Compact mode (default) returns only interactive elements; filter / depth / ref or selector (subtree) / maxChars (default 20k) keep it small. Traverses open + closed shadow DOM, slots and same-origin iframes. source: "native" returns Chrome's own accessibility tree instead of the DOM-derived one (details). |
browser_screenshot |
Capture a tab as an image over CDP — maxWidth / scale / jpeg to cut tokens, fullPage for the whole page, region to zoom; reports the pixel → x/y mapping |
browser_text |
Extract text from page or element (incl. shadow DOM); mode:"article" = main content only; offset paging |
browser_find |
Query elements by natural language ("search input", "Save button") — tokenized, role-aware, shadow DOM + same-origin iframes |
Interact
| Tool | What it does |
|---|---|
browser_act |
Safely click/type/select/focus/hover/keypress/scroll/upload against a browser_observe snapshot |
browser_click |
Real (trusted) click by ref, CSS selector or x/y — clickCount 1-3, modifiers; pierces same-origin iframes and shadow DOM |
browser_click_text |
Click by visible text (case-insensitive, shadow DOM too) with a real click on the owning control. Works through React portals and overlays |
browser_type |
Real key presses into inputs and contenteditable fields (or the focused field); returns the resulting value |
browser_press_key |
Real key presses, combos (Enter, Tab, ctrl+a), sequences ("ArrowDown ArrowDown Enter") and repeat |
browser_scroll |
Scroll pages and virtual containers, or wheel-scroll at x/y |
browser_hover |
Trigger tooltips and dropdowns (ref, selector or x/y) |
browser_select |
Pick from native <select> dropdowns |
browser_wait |
Wait for elements (any visible match), text, a URL change, or a delay |
browser_fill_form |
Fill multiple form fields in one call (React/Vue-safe setters; selects by value or label) |
browser_drag |
Drag element-to-element (uses CDP for reliability) |
browser_upload_file |
Upload files through <input type="file"> (CDP, strict-CSP safe), or bytes / a screenshot into an input or a drop zone |
Uploading files — no file dialog
browser_upload_file injects local files into an <input type="file"> as if the user picked them: the native dialog never opens, and input/change events fire afterwards so React/Vue forms react.
browser_upload_file { tabId: 15, selector: "#resume", filePath: "/Users/me/resume.pdf" }
browser_upload_file { tabId: 15, ref: "e12", files: ["/tmp/a.png", "/tmp/b.png"] }
Paths are absolute and local to the machine running the browser. Omit ref/selector to auto-target the first file input on the page; several files at once need an input with multiple.
Chain, replay & record
| Tool | What it does |
|---|---|
browser_batch |
Run several tool calls in one round-trip; stops at the first failure |
browser_shortcuts |
Save a flow with {{variables}}, replay it in one call |
browser_gif |
Record a flow as an animated GIF — a frame after every page-changing action, clicks marked with a red ring; export writes the file and returns its path |
Navigate
| Tool | What it does |
|---|---|
browser_navigate |
Go to a URL in a tab (tabId optional, defaults to active); replaces a frozen tab |
browser_tabs |
List / create (active:false for background) / close / focus / reload / lock / unlock tabs |
browser_resize_window |
Resize or maximize the window holding a tab (responsive testing) |
browser_emulate |
Offline / slow 3G, CPU throttling, geolocation, dark mode, reduced motion or another user agent for one tab |
browser_list_browsers / browser_select_browser |
See the connected browsers (profiles) and route this session to one |
Debug & Advanced
| Tool | What it does |
|---|---|
browser_console |
The page's console output (log, info, warn, error, debug, uncaught errors) — per-tab, capped at 200 entries; pattern / level / limit; browser: true adds Chrome-reported messages and DevTools Issues |
browser_intercept |
Block, redirect or set request headers per tab (Chrome session rules); captures + HAR export. mock/log rules are ledger-only |
browser_network |
Requests with status codes, durations and failures — per-tab; urlPattern, regex filter, type, failed, limit; requestId for one request's (redacted) headers |
browser_evaluate |
Run JavaScript like the DevTools console: top-level await, last value returned, not blocked by CSP |
browser_handle_dialog |
Dismiss/accept an open alert/confirm/prompt via CDP (works on frozen pages) |
browser_run_action |
Run a self-contained JS action object via CDP |
Use a site's own tools (WebMCP)
| Tool | What it does |
|---|---|
browser_webmcp_list |
List the tools a page registers through WebMCP (document.modelContext / <form toolname>): name, input schema, frame, annotations |
browser_webmcp_call |
Call one of those tools with an input object instead of driving its UI — consequential tools and autosubmit forms need confirm: true |
Discover
| Tool | What it does |
|---|---|
browser_tools |
List, search and activate the other tools on demand (progressive disclosure) |
The tables in What It Can Do are the index. This section explains every tool: what it is for, its parameters (defaults in parentheses) and the behavior worth knowing. The exact JSON schema of any tool is also available to the agent at runtime through browser_tools { action: "details", tool: "…" }.
Conventions
tabIdis required (an integer frombrowser_tabs { action: "list" }) on every tool that touches a page. It is optional onbrowser_navigate,browser_batchandbrowser_shortcuts(where it is the default for the steps), and absent on the browser-level tools (browser_list_browsers,browser_select_browser,browser_tools).- Targeting an element: a
reffrombrowser_snapshot/browser_find, or a CSSselector. Pointer tools also take viewportx/yin CSS pixels; a screenshot result tells you how image pixels map to them. trusted(on click, click_text, type, press_key and hover): real input over CDP is the default, so the page seesisTrustedevents.trusted: falsesends synthetic DOM events with no debugger banner.- Results are JSON text (screenshots are images). A failure is a real
isErrorresult that carries the full payload. - Timeouts and retries per tool are in the table at the end.
One atomic, compact reading of a tab for the safe Observe → Act loop: a snapshotId, a documentVersion, and elements — each with a ref, role, name, bbox ([x, y, width, height]) and the allowedActions valid for it right now. Look-alike controls (same role and name, such as an icon-only Edit button in every table row) also carry context: the text of their row. Its refs belong to that snapshot and are accepted by browser_act.
tabId,mode("compact", the only mode today),maxElements(1–1000, default 500)- Read-only: a call that times out is retried.
The accessibility tree of a tab, with a ref (e12) on every element that the ref-based tools accept. It sees shadow DOM, slotted content and same-origin iframes. After an action, elements that are new since the previous snapshot are tagged isNew: true.
tabId- Scope:
selector(a CSS subtree),ref(the subtree of an earlier ref),depth(max nesting,0= top level only) - Size:
compact(defaulttrue: only interactive elements, landmarks and headings; look-alike controls — same role and name — carry their table row's text ascontext),filter("interactive"|"all", an alias that wins overcompact),maxChars(500–200000, default 20000; the result saystruncated: truewhen it was cut) source:"dom"(default, no debugger) or"native"— Chrome's own accessibility tree; see Native accessibility tree- Read-only: retried on timeout.
An image of the tab, captured over CDP. The result also carries a small JSON block that maps image pixels back to the x / y that click/hover/scroll take, so a point you spot in the image can be clicked directly.
tabId,format("png"default |"jpeg"),quality(0–100, default 80, JPEG only)- Cut image tokens:
maxWidth(100–4000, keeps the aspect ratio),scale(0.05–4), orformat: "jpeg" fullPage(defaultfalse) captures the whole scrollable page;region{ x, y, width, height }zooms into a viewport rectangle (scaleup to 4, default 2 there)reforselector: just that element, scrolled into view, with a 4 px margin (scaledefaults to 1). The result sayselement.clipped: truewhen the element is bigger than the viewport; an element that is gone or has no box is an error, never a full-viewport picture.- A background tab is shown for a moment and the tab you were on is switched straight back, because Chrome does not paint hidden tabs. The agent's blue control frame and the agent cursor are never in the picture.
- Read-only: retried on timeout.
The visible text of the page or of an element, including shadow DOM. The cheapest way to read a page. Returns url, title, text, length and truncated.
tabId,selector(every visible match is included in page order;matchessays how many when there is more than one, so"h1, .price"returns both)maxLength(1–100000, default 5000 characters),offset(page through a long text with thenextOffsetof a truncated result)mode:"all"(default) or"article"— main content only, without navigation, headers, footers, sidebars and banners- Read-only: retried on timeout.
Locates elements from a natural-language description ("login button", "search input", "Open alert dialog") without taking a full snapshot. It matches every word against accessible names, labels, placeholders and attributes, understands role words (button, link, input, checkbox, tab, menu…), looks inside shadow DOM and same-origin iframes, and prefers the control over its wrappers. A control inside a table row also matches words from that row, so "edit carton" ranks the Edit button of the carton row first; such matches report the row as context. Returns refs for every ref tool.
tabId,query,limit(default 10),role(only elements with this ARIA role)- Read-only: retried on timeout.
The safe way to act: runs one validated action against an immutable browser_observe snapshot. Before acting it checks snapshot ownership, document/route identity, target semantics, geometry, visibility, enabled state, allowed actions and click-point occlusion; a failed check returns a compact error (DOCUMENT_CHANGED, STALE_STATE, TARGET_OCCLUDED, ACTION_NOT_ALLOWED) and nothing is clicked — call browser_observe again.
tabId,snapshotId,ref(optional only for a page scroll)action:click|type|select|hover|scroll|keypress|focus|upload- Per action:
text+clear(type);value/label/index(select);key+modifiers(keypress);deltaX/deltaY(scroll,deltaYdefaults to 500);filePath/files(upload)
A real mouse click over CDP: focus moves, default actions run and the page sees isTrusted: true, even in a background window. If a ref has gone stale but the element still exists it is re-found automatically (via: "fallback"). The result's at is the viewport point that was clicked (the element's centre for ref / selector); browser_click_text and a browser_act click report it too.
tabId, and a target (one is required):ref,selector, orx+ybutton("left"|"right"|"middle"),doubleClick,clickCount(1–3, overridesdoubleClick;3selects a line),modifiers(ctrl|alt|shift|meta— ctrl+click opens a link in a new tab),trusted- Falls back to synthetic DOM events if the debugger cannot attach.
Clicks the element whose visible text you name — the tool for React dropdowns, portals and overlays that never show up in a snapshot. Matching is case-insensitive and ignores CSS text-transform; it covers accessible names, aria-label / title and composed text across shadow DOM and same-origin iframes, then clicks the control that owns the text (the <button> around a <span>) with a real mouse click.
tabId,text,index(0-based, which match to click when there are several),exact(whole text instead of a substring),trusted
Types with real key presses (keydown / keypress / input / keyup per character), so autocomplete and lookup widgets react as they do for a person. Returns the field's value after typing.
tabId,text,clear(defaultfalse),trusted- Target:
reforselector; with neither, it types into the element that already has focus. - Like a user,
change/ blur fire only when focus leaves the field — follow withbrowser_press_key { key: "Tab" }to commit.
Real key presses over CDP: Tab moves focus (and fires blur), Enter submits, arrows drive autocomplete menus.
tabId,key: a name (Enter,Escape,Tab,ArrowDown,a), a combo (ctrl+a), or a space-separated sequence ("ArrowDown ArrowDown Enter","ctrl+a Backspace")repeat(1–100, repeats the whole sequence),modifiers,ref/selector(focus this element first),trusted
Scrolls the page or an element, including the virtual containers social feeds use (the result then has refsMayBeStale: true — re-snapshot before the next interaction).
tabId,direction(up|down|left|right, defaultdown),amount(pixels, default 500)selector(a scroll container),toElement(a ref or selector to bring into view),position("top"|"bottom")x+y: send a real mouse-wheel event at that point, scrolling whatever is under it (inner panels, maps, virtual lists)
A real mouse move, to trigger tooltips, dropdown menus and hover-only states.
tabId,ref/selector/x+y,trusted
Picks an option in a native <select>. For custom dropdowns built from <div>s, click the trigger and then click the option (browser_click_text is handy).
tabId,ref/selector, and one ofvalue,label(the option text) orindex(0-based)
Waits for a condition instead of a fragile sleep — useful on SPAs. Visible matches include shadow DOM and same-origin iframes.
tabId,timeout(ms, default 10000)selector+state("visible"default |"hidden"|"attached");text(waits until it is on the page, or withstate: "hidden"until it is gone; case-insensitive);urlIncludes(waits until the tab URL contains the string, e.g. after a client-side navigation)delay(ms): a fixed pause that ignores the other options. Insidebrowser_batcha puredelaysleeps locally instead of costing a daemon call.
Fills several fields in one call instead of one browser_type per field. Handles text inputs, selects, checkboxes and contentEditable elements, using setters that React and Vue notice.
tabId,fields: a list of{ ref | selector, value (string or boolean), clear (default true) }submit(defaultfalse): submit the form after filling
Drag-and-drop with CDP mouse events.
tabId- Start:
startRef/startSelector/startX+startY; end:endRef/endSelector/endX+endY steps(default 10): intermediate mouse moves along the way
Uploads files without ever opening the file dialog — see Uploading files above for examples. The input is found by ref, selector, or (with neither) as the first input[type="file"] on the page; input / change events fire afterwards.
tabId,filePath(one file) orfiles(several; the input must allowmultiple). Paths are absolute and local to the machine running the browser.- No local file?
imageBase64(+fileName,mimeType) uploads bytes directly, andfromScreenshot: trueuploads a fresh PNG screenshot (screenshotTabId,region). Either goes into a file input, or is dropped onto a drop zone given byref/selector/x+y.
Runs a list of tool calls in one call, in order — the biggest round-trip saver when you already know the next few steps (click a field → type → press Tab → wait → read text).
browser_batch {
tabId: 15,
actions: [
{ tool: "browser_click", params: { ref: "e3" } },
{ tool: "browser_type", params: { ref: "e5", text: "hello@example.com" } },
{ tool: "browser_press_key", params: { key: "Tab" } },
{ tool: "browser_wait", params: { text: "Welcome" } },
{ tool: "browser_text", params: {} }
],
output: "last"
}
→ "batch: 5/5 steps ok", the last step's result, and per-step timings
actions: 1–200 steps of{ tool, params }. The top-leveltabIdis the default for every step that does not set its own.- It stops at the first failing step and skips the rest, so the agent never acts on a page that did not reach the expected state;
continueOnError: truekeeps going. The result is flaggedisErrorif any step failed. output:"all"(default, every step's result),"last"(only the last step plus any failure) or"errors"(only failures). Uselast/errorsfor long batches to save tokens.- Each step is validated and executed exactly as if it were called alone, so its own timeout and error payload apply. Every step is still a call against the per-session rate limit; a step the limit rejected never ran, so the batch waits out the window and resends it (up to 3 times).
- If a step replaces a frozen tab (
replacedTabId), later steps follow the new tab. browser_batch,browser_shortcutsandbrowser_toolscannot be used as steps.
Saved, replayable flows: a named browser_batch with {{variables}}. Save a flow once, then run it in one call with different values — a form you fill every day, a report you export.
browser_shortcuts {
action: "save", name: "add-todo", description: "Add an item to the todo list",
actions: [
{ tool: "browser_click", params: { selector: "#new-todo" } },
{ tool: "browser_type", params: { text: "{{item}}" } },
{ tool: "browser_press_key", params: { key: "Enter" } }
]
}
→ { success: true, saved: { name: "add-todo", steps: 3, variables: ["item"], … }, file: "…/shortcuts.json" }
browser_shortcuts { action: "run", name: "add-todo", tabId: 15, vars: { item: "Buy milk" } }
action:list(name, description, step count, variables),show(the full definition),save,run,deletename: letters, digits,_,.,-(max 64).savetakesdescription(≤500 characters) andactions(≤200 steps, the same format asbrowser_batch); saving an existing name overwrites it.{{variable}}placeholders can sit in any string parameter; the variable list is detected automatically. A string that is exactly one variable keeps the value's type, so"{{count}}"can carry a number or a boolean.runtakesvars,tabId(the default tab for steps without their own),continueOnErrorandoutput(default"last"). A missing variable is an error that lists the variables the shortcut needs. It runs as abrowser_batch, so it stops at the first failing step.- Stored locally in
~/.browser-controller/shortcuts.json(setBC_SHORTCUTS_FILEto move it); nothing leaves the machine.
Records what the agent does in a tab as an animated GIF, so you can see what it did without being at the screen. While a recording is on, a frame is captured after every page-changing action; export encodes the frames and writes a .gif on your machine. The file is written by the MCP server and only its path goes back to the agent — the GIF bytes never enter the agent's context.
browser_gif { tabId: 15, action: "start" }
→ { success: true, recording: true, frames: 1, width: 800, maxFrames: 300 }
browser_navigate { tabId: 15, url: "https://example.com/login" } // each of these adds a frame
browser_fill_form { tabId: 15, fields: [ … ], submit: true }
browser_click { tabId: 15, ref: "e7" } // red ring where it clicked
browser_gif { tabId: 15, action: "export", path: "/Users/me/Desktop/login-flow.gif" }
→ { success: true, frames: 4, width: 800, height: 450, bytes: 118342, path: "/Users/me/Desktop/login-flow.gif" }
action selects what to do:
action |
What it does |
|---|---|
start |
Begin recording this tab and capture the first frame (how the page looks now). Options: width (maximum frame width, 200–1600 px, default 800), maxFrames (1–500, default 300), activate (default true, see below). Starting again on the same tab throws the earlier frames away. |
frame |
Add a frame right now, e.g. to capture the end state after a read-only step. Needs a prior start; works while paused. |
stop |
Pause capturing but keep the frames. Returns frames, skipped and seconds. |
status |
Returns recording, frames and skipped. |
export |
Stop, encode and write the file. path (default ~/Downloads/browser-recording-<timestamp>.gif, or the OS temp folder if ~/Downloads does not exist; missing folders are created). clear (default true) drops the frames afterwards. Returns frames, width, height, bytes and path. Errors if nothing was recorded. |
clear |
Discard the frames without exporting. |
How it behaves:
- What gets a frame. A frame follows each successful page-changing action on the recorded tab:
browser_navigate,click,click_text,type,press_key,scroll,hover,select,drag,fill_form,act,upload_file,handle_dialog,run_action,evaluateandwait. Any tool not in that list (snapshot,text,find,observe,screenshot,console,network,tabs,resize_window…) adds none, and neither does an action that failed. Steps inside abrowser_batchor a shortcut are captured one by one. - The red ring. Every click is marked with a red ring where it landed:
browser_click(byref,selectororx/y),browser_click_textandbrowser_act { action: "click" }. A hover or wheel-scroll given asx/ygets one too. The point is the one the tool reports asatin its result. - Timing. Each frame is shown for as long as the real pause before the next action, limited to between 0.4 s and 2.5 s; the last frame holds for 1.5 s. Frames are JPEG screenshots (quality 70), downscaled when the page is wider than
width. - Palette. The GIF uses a fixed 256-colour palette. Flat UI and text look fine; photos and gradients show banding.
- Frame cap. After
maxFramesframes, further ones are skipped and counted inskipped. - Background tabs. Chrome does not paint hidden tabs. With
activate: true(default) each frame briefly shows the tab (~150 ms, asbrowser_screenshotdoes) and switches back; withactivate: falseframes are only taken while the tab is already visible, and the rest are counted inskipped. - Where frames live. Per tab, in the extension, until you export or clear them. A recording follows a frozen tab that
browser_navigate/browser_tabs reloadreplaced. Large GIFs travel from the extension to the server in parts of up to 600 KB, which the server reassembles. - Timeout: 120 s; not retried.
Goes to a URL in a tab and, by default, returns a compact snapshot of the new page so the agent can act immediately.
url: a URL, or"back"/"forward"to move through the tab's historytabId: optional — it defaults to the active tab, but pass it explicitly so you never navigate the page the user is readingwaitUntil:"load"(default) |"domcontentloaded";snapshot(defaulttrue): setfalseto skip the inline snapshot and save tokens- A hash-only change resolves as soon as the URL is set. A frozen tab is replaced in place, and the result carries the new
tabId.
Lists and manages tabs. This is where every tabId comes from.
action:list,create,close,focus,reload,lock,unlock;tabIdis required for all butlistandcreatelist:fullUrls(don't shorten long URLs). Each tab shows itslockedBy, andcontrolledBywhen another agent's session acted on that unlocked tab in the last 30 s (steer clear or lock it first).create:url,active(falseopens it in the background so the user keeps their tab)focus:window: truealso brings the tab's window to the frontreload:bypassCacheskips the HTTP cache; it also recovers a frozen (TAB_WEDGED) tablock/unlock: claim a tab for the calling agent so other agents queue behind it instead of racing — see Multi-agent coordinationclose,focusandreloadskip the per-tab queue, so they still work on a tab frozen by a native dialog (lock ownership is still checked).
Resizes the browser window that holds a tab — to test a responsive layout at 390×844, say — or sets it to maximized, minimized or fullscreen. It affects the whole window the user sees, so prefer a separate window for experiments, and it is refused if another session has the tab locked.
browser_resize_window { tabId: 15, width: 390, height: 844 }
→ { success: true, windowId: 7, state: "normal", width: 390, height: 844, viewport: { width: 375, height: 760 } }
tabId(any tab in the window),width/height(200–10000 px),state("normal"|"maximized"|"minimized"|"fullscreen"); at least one is required.- Sizes apply to a normal window, so giving
width/heightrestores a maximized window first. With a non-normalstate,widthandheightare ignored. viewportis the page'sinnerWidth×innerHeightafter the resize (left out on protected pages).
Puts one tab in other conditions through its debugger session — offline or on slow 3G, a slower CPU, another location, dark mode, reduced motion, another user agent — to see how the page copes.
browser_emulate { tabId: 15, network: "slow-3g", cpuThrottling: 4, colorScheme: "dark" }
→ { success: true, active: { network: "slow-3g", cpuThrottling: 4, colorScheme: "dark" }, note: "…" }
network:"offline","slow-3g","fast-3g"(Chrome DevTools' presets) or"none";cpuThrottling(1–20,1= off)geolocation{ latitude, longitude, accuracy }(nullclears) — the page still needs location permissioncolorScheme("light"|"dark"|"none"),reducedMotion("reduce"|"none"),userAgent(""clears)- Settings merge with what is already active, and the result lists everything that is (
active); with no setting the call only reports it. - Overrides last while the debugger session stays attached — it is kept attached instead of being dropped after 30 s idle, so the yellow banner stays.
reset: trueclears everything (detaches the session); closing the banner clears it too.
Lists every browser (Chrome profile or instance with the extension) connected to the daemon: browserId, label, connectedAt, a default flag on the most recently connected one, and selected on the one this session uses. With a single browser connected you never need it. Answered by the daemon itself; read-only, so it is retried.
Routes all of this session's tool calls to one connected browser, by browserId or label from browser_list_browsers. "auto" goes back to the default (the most recently connected browser). Tab ids belong to their browser, so list the tabs again after switching.
What the page logged in a tab: log, info, warn, error, debug and uncaught errors. The buffer is per tab and holds 200 entries.
tabId,pattern(case-insensitive regex, e.g."error|fail"),level(one level or a list),limit(1–200, the most recent N matches)browser(defaultfalse): also returnbrowser.messages— what Chrome itself reported for the page and the page's console never sees: failed resource loads (Failed to load resource … 404), deprecations, interventions, violations, security warnings — andbrowser.issues, the DevTools Issues (CORS, CSP, mixed content, cookies, form fields…) as{ code, type, url, count }. Both cover the current document from its load, read through the debugger (yellow banner);pattern/level/limitapply to them too.clear(defaultfalse): empty the tab's buffer after reading (withbrowser, Chrome's log too). Because it can mutate, the tool is never retried on timeout.
The requests a tab made: id, method, URL, status, type and durationMs; failed ones carry the error. Per tab, capped at 200 entries. URLs longer than 200 characters are shortened in the list (…[+N chars]).
tabId,urlPattern(substring, e.g."/api/"),filter(URL regex),type(one or a list:xhr/fetch,document,script,stylesheet,image,font,media,websocket, … — any webRequest resource type),failed(only network errors and HTTP 4xx/5xx),limit(1–200),fullUrls(keep long URLs whole)requestId(anidfrom the list): that one request in full, with its request and response headers. Values of credential and session headers (Authorization,Cookie,Set-Cookie, API keys, tokens…) read[redacted]. Response bodies are not captured.clear(defaultfalse): empty the buffer after reading — so, like the console, it is never retried.
Captures, blocks, redirects or adds request headers to a tab's network traffic using Chrome's declarativeNetRequest session rules.
browser_intercept { tabId: 15, action: "set-rules", rules: [
{ id: "no-analytics", match: "analytics\\.example\\.com", action: "block" },
{ id: "debug-header", match: "/api/", types: ["xmlhttprequest"], action: "header", headers: { "X-Debug": "1" } }
] }
→ { success: true, enforcement: "full", scope: "tabs:15", ruleCount: 2, enforced: 2 }
action:set-rules,list-rules,clear-rules,list-captures,export-har- Rules (
set-rules, up to 50):match(a URL regex, ≤500 characters; match-all patterns such as.*are rejected),action(block|redirect|header|log|mock),id,types(resource types such asxmlhttprequest,script,image),tabIds,enabled(defaulttrue), and per actionredirectUrl,headers(set on matching requests),mockStatus/mockBody. - Scope: rules apply to the
tabIdyou pass (or to each rule'stabIds); with neither they are global. Callingset-rulesagain replaces the rules of that scope, and an empty list removes them. - Enforcement is reported, not assumed. The result has
enforcement:"full","partial"or"capture-only", plus anenforcedcount and anunsupportedlist with the reason for each rule Chrome cannot apply. Chrome cannot fake a response body, somockrules are only recorded in the capture ledger (and make the result"partial");logrules are capture-only by design and are not counted as enforced. If Chrome rejects the rule set (its regex dialect is stricter than JavaScript's), enforcement falls back tocapture-onlywith areason. list-rules: the stored rules and whether each is enforced.clear-rules: withtabId, removes that tab's rules (global rules stay); without it, removes all.list-captures(needstabId): the tab's recorded requests, withintercept.matchedRuleIdson those a rule matched.filter(URL regex) andlimit(1–200) narrow it.export-har(needstabId): a HAR 1.2 file of the tab's requests with their headers and duration;filterandlimitnarrow it likelist-captures. Credential and session header values are redacted (_redacted: true), and bodies are not captured.
Runs JavaScript in a tab the way you would in the DevTools console: top-level await works, several statements are fine, and the value of the last expression is returned (no IIFE or return needed). DOM nodes come back as readable descriptions.
browser_evaluate { tabId: 15, expression: "const r = await fetch('/api/items'); (await r.json()).items.length" }
tabId,expression,timeout(1000–120000 ms, default 30000; awaits are included)mode:"cdp"(default — page CSP does not block it; the yellow "being debugged" banner shows while the debugger is attached; if the debugger cannot attach, it falls back to thescriptingpath) or"scripting"(viachrome.scriptingin the page's MAIN world: no banner, but a strict CSP can reject it and top-levelawaitis not available)- Prefer
browser_click/browser_typeover dispatching events by hand. Powerful and non-idempotent, so never retried.
Accepts or dismisses a native alert / confirm / prompt. A dialog freezes the page's JavaScript thread, so this tool goes straight to CDP and skips the per-tab queue — it works on a frozen page, and unblocks every other tool on that tab.
tabId,action("accept"|"dismiss"),promptText(the text to enter for aprompt())
Runs a self-contained JavaScript action object in the page via CDP. The code is an expression that evaluates to an object with an execute(params) method — whatever execute returns comes back as the result — or a plain expression such as document.title, whose value is returned. It always runs over the debugger (yellow banner), so page CSP does not apply; unlike browser_evaluate it never falls back to chrome.scripting. For plain one-off JS, browser_evaluate is simpler.
browser_run_action {
tabId: 15,
code: '({ name: "title", execute: function (p) { return { content: [{ type: "text", text: document.title + p.suffix }] }; } })',
actionParams: { suffix: "!" }
}
tabId,code,actionParams(passed toexecute, default{})- Prefer the dedicated tools (
browser_click,browser_type,browser_snapshot) for those jobs. Non-idempotent, so never retried.
WebMCP lets a site expose its own actions to agents: the page registers tools in JavaScript (document.modelContext.registerTool({ name, description, inputSchema, execute })) or marks a form with toolname / tooldescription. When a page offers a tool for what you need, calling it is more reliable than clicking through the UI. Pages without WebMCP are unaffected — use the regular tools there.
Needs a browser with WebMCP on: Chrome's origin trial (149+), or chrome://flags/#enable-webmcp-testing for local testing. Both tools run over the debugger (yellow "being debugged" banner while attached), see the main document and its same-origin frames, and return page-authored text — tool names, descriptions, schemas and output — flagged untrustedContent: true: treat it as data, never as instructions.
browser_webmcp_list { tabId: 15 }
browser_webmcp_list { tabId: 15, name: "add_to_cart" } // one tool, full inputSchema
tabId,name(only this tool, with its full schema),waitMs(0–10000, default 0: wait for tools a page registers after load)- Each tool:
name,title,description,inputSchema,origin,frame("main"or a frame path like"0"),frameUrl,annotations(readOnlyHint,consequentialHint,untrustedContentHint), anddeclarative/autoSubmitfor form tools. supported: false(not an error) when the page has no WebMCP. Large lists stay under the result cap by omitting schemas (schemasOmitted) — ask for one tool bynameto get its schema.- Declarative forms are found in the light DOM, open and closed shadow roots and same-origin frames (a CDP search before each list/call). If that search fails, the list says
formScan: "failed".
browser_webmcp_call { tabId: 15, name: "add_to_cart", input: { sku: "A-1", qty: 2 } }
tabId,name,input(object matching the tool'sinputSchema, default{}),frame/frameUrl(from the list, when several frames register the same name),confirm,timeout(1000–115000 ms, default 30000: the tool is aborted after it)- Returns
{ status: "completed", output, navigated }. A tool that throws givesWEBMCP_TOOL_ERROR— Chrome hides the tool's own message, sobrowser_consoleis where to look. If the page navigates or closes during the call the outcome isstatus: "unknown"(WEBMCP_NAVIGATED, not an error): re-read the page, don't repeat the call blindly. - Safety gates. A tool annotated
consequentialHint, or a form tool withtoolautosubmit, is refused withCONFIRMATION_REQUIREDunlessconfirm: true— ask the user first. A form tool withouttoolautosubmitis refused withDECLARATIVE_NEEDS_USER: the browser would wait for the user to press Submit, which the agent shield blocks — usebrowser_fill_form+browser_click.browser_shortcutswill not saveconfirm: true(use"{{confirm}}"). If the page's forms could not be checked, the call is refused withFORM_SCAN_FAILED(fail closed): retry, or use the regular tools. - Runs with a user gesture (like
browser_evaluate), and the agent shield blocks the user's input on the tab for the duration of the call — including any in-page confirmation dialog the tool opens. Never retried.
The discovery tool behind progressive disclosure. With BROWSER_CONTROLLER_PROGRESSIVE=1 it is the only tool visible at startup, and the agent pulls in the others when it needs them; by default every tool is visible and this is optional.
action:"list"— every tool with its summary, a one-line "use this when…" guidance and whether it is active, preceded by a short task → tool guide;"search"(withquery) — tools whose name, summary or description match any word;"details"(withtool) — the full JSON schema and description of one tool, and activates it so it can be called directly- It cannot be used as a step inside
browser_batchorbrowser_shortcuts.
Each tool declares its transport timeout next to its definition (mcp-server/src/tools/); a tool without one falls back to the bridge default.
| Timeout | Tools |
|---|---|
| 5 s | browser_press_key, browser_hover, browser_network, browser_handle_dialog, browser_list_browsers, browser_select_browser |
| 10 s | browser_click, browser_click_text, browser_scroll, browser_select, browser_drag, browser_observe, browser_intercept, browser_resize_window, browser_console, browser_emulate |
| 15 s | browser_type, browser_fill_form, browser_upload_file, browser_act, browser_snapshot, browser_screenshot, browser_text, browser_find |
| 30 s | browser_run_action |
| 35 s | browser_tabs (a reload waits up to 30 s for the new document) |
| 60 s | browser_navigate, browser_wait |
| 25 s | browser_webmcp_list (waitMs up to 10 s, plus the declarative-form scan) |
| 120 s | browser_gif |
| 125 s | browser_evaluate (always longer than its own timeout, which can be up to 120 s) |
| 130 s | browser_webmcp_call (always longer than its own timeout, up to 115 s, plus the form scan) |
| 300 s | browser_batch, browser_shortcuts (each step keeps its own timeout) |
Only the read-only tools are retried on timeout: browser_observe, browser_snapshot, browser_screenshot, browser_text, browser_find, browser_list_browsers and browser_webmcp_list. Every other tool — including browser_console and browser_network (which mutate on clear: true) — is never retried, so a click cannot fire twice.
| Browser Controller | Playwright MCP | Chrome DevTools MCP | |
|---|---|---|---|
| Uses your existing browser | Yes | No, launches new | Partial, needs debug port |
| Sessions and cookies | Already there | Fresh profile | Manual setup |
| Works behind corporate SSO | Yes | No | Depends |
| Multiple agents, multiple tabs | Yes | No | No |
| Tab-targeting (won't hijack active tab) | Yes | N/A | No |
| Authenticated local connection | Yes | N/A | No |
| Calls a site's WebMCP tools | Yes, with confirmation gates | Yes (on by default) | Opt-in (--categoryExperimentalWebmcp) |
| Setup | Build from source + extension | Headless browser | Chrome with --remote-debugging-port |
| Env var | Default | What it does |
|---|---|---|
WS_PORT |
7225 |
WebSocket port the daemon uses for the extension connection |
BROWSER_CONTROLLER_PROGRESSIVE |
(unset) | Set to 1 to enable progressive tool disclosure: only the browser_tools meta tool is visible at startup (~150 tokens instead of loading all 33 definitions). The agent discovers tools via browser_tools {action:"list"/"search"} and activates them with {action:"details", tool:"…"}. Default (unset) shows all tools upfront — safe for agents whose instructions call tools directly. |
MCP_AGENT_NAME |
(auto: IDE name) | Override the agent name shown in the popup (same as --agent) |
BC_SHORTCUTS_FILE |
~/.browser-controller/shortcuts.json |
Where browser_shortcuts keeps the saved flows |
The daemon keeps everything in ~/.browser-controller/ (Windows: %USERPROFILE%\.browser-controller\):
| File | Purpose |
|---|---|
enrollment.json |
One-time pairing secret for the extension (mode 0600) |
token.json |
Auth token the extension must present on every WebSocket connection (mode 0600) |
daemon.sock |
The IPC socket thin clients connect to (AF_UNIX on mac/linux; named pipe on Windows) |
daemon.json |
Daemon metadata (pid, port, start time) — used to detect a running daemon |
daemon.log |
Daemon stdout/stderr when spawned by a client |
shortcuts.json |
Flows saved by browser_shortcuts (created on the first save) |
To fully reset: stop your MCP clients, delete the folder, and the next run recreates it with fresh secrets.
- The daemon is auto-spawned the first time any client runs and left running detached.
- Connection drops use exponential backoff (1s → 30s), ping/pong health checks every 10s; a client that misses 3 pongs is evicted.
- Per-session rate limit of 120 calls/min protects the daemon from a runaway agent loop.
- Per-tool timeouts (5–15s for most actions, 60s for navigation), co-located with each tool's definition so they can't drift from the registry.
- Idempotent read tools (snapshot, screenshot, text, find) are retried on timeout; side-effecting tools (click, type, navigate, evaluate) — and
console/network(which mutate onclear:true) — are never retried, so a click can't fire twice. - If another process already holds port 7225, the daemon refuses to start rather than killing a process it didn't spawn — it reports the conflict so you can resolve it deliberately.
After transport authentication, the daemon and extension exchange a protocol major, their application versions, and supported capabilities before the connection becomes ready for tool calls. The protocol major and required capabilities decide compatibility; different application patch versions are allowed when that contract still matches. During the migration window, peers that do not advertise a protocol version are recognized as legacy, while an explicitly different protocol major is rejected. This lets rolling upgrades fail clearly without confusing application version numbers with wire compatibility.
Multiple Chrome profiles
Run two daemons on different ports by setting WS_PORT per client:
{
"mcpServers": {
"browser-work": {
"command": "node",
"args": ["/path/to/browser-controller/mcp-server/dist/index.js"]
},
"browser-personal": {
"command": "node",
"args": ["/path/to/browser-controller/mcp-server/dist/index.js"],
"env": { "WS_PORT": "9333" }
}
}
}Update the port in each extension popup to match.
Everything stays on your machine. The extension connects to the daemon via an authenticated WebSocket on localhost; MCP clients connect to the daemon via a local IPC socket. No cloud, no proxy, nothing leaves your browser.
browser-controller/
├── mcp-server/ MCP server (TypeScript)
│ └── src/
│ ├── daemon.ts Single multi-client daemon (owns WS :7225)
│ ├── daemon-config.ts IPC protocol, paths, auth/enrollment tokens
│ ├── index.ts Thin stdio MCP client (spawns daemon, multiplexes)
│ ├── bridge.ts Extension WS server + cross-platform port probe
│ ├── register-tools.ts Progressive-disclosure wiring
│ ├── native-host.ts Native messaging pairing helper (npm run setup:pairing)
│ └── tools/ Tool definitions (33 + the browser_tools meta tool), registry pattern
├── extension/ Chrome extension (Manifest V3, plain JS, ES modules)
│ ├── background.js Wiring only (~30 lines): inject router, register events, connect
│ ├── lib/ state (buffers/locks/persistence), connection (WS lifecycle),
│ │ router (dispatch + mutex/locks + control shield), page-exec,
│ │ overlay, lock-ops, tab-concurrency (pure, unit-tested)
│ ├── handlers/ Tool implementations: navigation, interaction, inspection, find, tabs, cdp,
│ │ agent-api (observe/act), ax-snapshot, intercept, gif
│ ├── utils/ navigation + smart-selector fallback resolution
│ ├── events.js chrome.* listeners (console capture, popup, webRequest, lifecycle)
│ ├── content.js Console capture
│ └── popup/ Fixed tabbed shell (Tabs · Agents · Settings) + collapsible activity bar
├── agent-config/ Pre-built configs for Cursor + Claude Code
│ ├── cursor/ Rules and commands
│ ├── skills/ Browser automation skill
│ └── setup.mjs One-command installer
└── tests/ 47 test files / 526 tests
Stack: TypeScript (strict) · MCP SDK · WebSocket · Chrome Extension Manifest V3 · Vitest
sequenceDiagram
autonumber
participant C as MCP client
participant W as wrapHandler — register-tools.ts
participant DC as DaemonClient — index.ts
participant DM as daemon — daemon.ts
participant BR as ExtensionBridge — bridge.ts
participant RT as router.js — extension
participant H as handler + safeExec
C->>W: tool call (JSON-RPC over stdio)
W->>DC: host.callTool(name, params)
DC->>DM: { kind: "call", id, tool, params } over the IPC socket
DM->>DM: rate-limit check + AbortController (per-tool timeout)
DM->>BR: bridge.callTool(tool, params, sessionId)
BR->>RT: WS { id, tool, params, sessionId, agentName }
RT->>RT: per-tab mutex + control shield
RT->>H: dispatch (observe/act: snapshot ownership checked first)
H->>H: chrome.scripting function injection or CDP
H-->>C: result returns along the same path (errors as isError results)
Every hop is authenticated (IPC socket and WebSocket both require the token) and bounded (per-session rate limit, per-tool timeout). The extension connection also completes the version/capability handshake before tool frames are routed. Only idempotent read tools are retried on timeout — a click can never fire twice. See Reliability for the full list.
browser_observe / browser_act run on a dedicated page runtime that is CSP-safe by construction: a one-time install via chrome.scripting function injection (no eval anywhere in the path), then per-action validation of snapshot ownership, document/route identity, target semantics, geometry, visibility, and click-point occlusion. If the page changed, the agent gets a compact state error (DOCUMENT_CHANGED, STALE_STATE, …) instead of a wrong click — see Safe Observe → Act workflow.
git clone https://github.com/compnew2006/browser-controller.git
cd browser-controller
npm install
npm run build
npm test
npm run lint
npm run test:coverage
npm audit --omit=dev| Command | What it does |
|---|---|
npm run build |
Compile TypeScript → mcp-server/dist/ |
npm run dev |
Watch mode |
npm test |
Run the full test suite |
npm run test:coverage |
Run the suite with enforced 80% thresholds for the deterministic protocol/action/state core |
npm run lint |
Lint the TypeScript server, extension, and tests with zero warnings allowed |
npm run typecheck |
Type check without emitting |
npm audit --omit=dev |
Check the installed production dependency graph for known vulnerabilities |
npm run setup:cursor |
Install Cursor rule + command |
npm run setup:claude |
Install Claude Code AGENTS.md |
npm run setup:pairing |
Register the native messaging pairing helper so the extension pairs itself |
npm run pairing:status |
Show which browsers the helper is registered with and the extension ID it allows |
npm run pairing:uninstall |
Remove the helper registration |
npm run smoke:webmcp |
Drive the real WebMCP handlers against an isolated headless Chrome (temporary profile, WebMCP on) and tests/fixtures/webmcp-demo.html. Needs a local Chrome ≥ 149 (CHROME_PATH to override); exits 2 if none is found. Re-run it after Chrome upgrades |
The suite covers the WebSocket bridge (including token-auth rejection and the unified error channel), the tool registry, daemon lifecycle (heartbeat eviction, rate limiting, IPC auth), per-tab concurrency (same-tab serialization + cross-tab parallelism), and extension behavior via a mocked chrome API (router dispatch, shield semantics, evaluate round-trip, iframe piercing, dialog rescue). CI runs the suite on Node 20 and 22, plus CodeQL and Scorecard scans.
git pull
npm install
npm run buildThen two manual steps: reload the extension in chrome://extensions (a running service worker never picks up file changes by itself), and restart the daemon — it's long-lived and doesn't reload dist/ either (kill it, or just restart your MCP client, and the next run respawns it on the new build).
Coming from a version without automatic pairing? Run npm run setup:pairing once before reloading the extension (see Pair the extension with the daemon). A secret you pasted earlier keeps working.
If the popup reports an incompatible protocol, a missing capability, or repeated handshake timeouts, the server and extension are usually from different checkouts/builds:
- Run
npm install && npm run buildin the repository used by your MCP configuration. - Stop the existing daemon or all MCP clients that own it, then restart the MCP client so a daemon starts from the new
dist/output. - Open
chrome://extensions, find Browser Controller, and click Reload so Chrome replaces the long-lived MV3 service worker. - Reopen the popup. If authentication rather than compatibility is failing, check
npm run pairing:statusand the port, or re-enter the enrollment secret; do not paste secrets into issue reports.
Restarting only one side is not sufficient after a protocol-changing update. Application-version differences can be harmless, but an explicit protocol-major mismatch will stay disconnected until the stale daemon or extension is replaced.
Does it work with my logged-in sessions?
That's the whole point. The extension runs inside your actual Chrome — same cookies, same sessions, same local storage. No re-authentication needed.
Does it send data anywhere?
No. The MCP clients, the daemon, and the extension all talk over localhost (IPC socket + WebSocket). Nothing leaves your machine. There's no analytics, no telemetry, no cloud component. See SECURITY.md for the threat model, auth design, and the first-contact TOFU window.
Which AI clients work?
Any MCP-compatible client. Cursor, Claude Desktop, Claude Code, Windsurf, Cline, and anything else that speaks the MCP protocol. Several of them can run at once against the same daemon.
Can two agents really work at the same time without breaking each other?
Yes. Each agent connects to the shared daemon, gets its own sessionId, and targets a specific tabId. Actions on the same tab serialize through a per-tab mutex; actions on different tabs run in parallel. Optionally an agent can lock a tab to claim exclusive access; other agents queue behind the lock rather than failing.
What if the agent acts on the wrong tab?
It can't — not silently. Every page-interaction tool requires a tabId, and if it's missing you get a clear tabId is required error. The agent can never accidentally act on the tab you happen to be looking at. (The one exception is browser_navigate without a tabId, which uses the active tab — but for multi-agent use you should always pass tabId.)
Why are there two secrets (enrollment + token)?
Without them, any local process on your machine could open a WebSocket to port 7225 and drive your authenticated browser sessions (your bank, your email, your company SSO). The enrollment secret pairs the extension with the daemon out-of-band, before any WebSocket exists — delivered by the local pairing helper (npm run setup:pairing) or pasted by hand; the auth token, which the extension then fetches with it, authenticates every connection. Both live in ~/.browser-controller/ with mode 0600.
Does it support WebMCP?
Yes. On a page that registers WebMCP tools, browser_webmcp_list shows them and browser_webmcp_call runs one with an input object, instead of the agent clicking through the UI. Consequential tools and auto-submitting forms need the user's confirmation (confirm: true), and the page's output is flagged as untrusted. You need Chrome 149+ with WebMCP on (origin trial sites, or chrome://flags/#enable-webmcp-testing). See Sites with their own tools (WebMCP).
How is this different from Playwright MCP or browser-use?
They launch a new browser instance from scratch — no state, no cookies, no sessions. You have to replay the full login flow every time. This connects to the browser you already have open with everything already loaded.
Bug reports, feature requests, and PRs are welcome at the issue tracker. Open an issue first for larger changes.
See SECURITY.md — localhost-only architecture, token + enrollment design, threat model, and reporting guidance.
