Better Browser MCP
Better Browser MCP is a local browser automation server for AI agents, providing MCP-compatible tools for driving a shared browser across one or many agents and tabs.
Navigate, click, hover, type, select options, press keys, wait, screenshot, snapshot, go back/forward, and read console logs
Manage tabs: list, open, close, rename, set active tab, bind/unbind tabs, and acquire/release per-tab locks
Wait for page content/text, extract attributes or text, evaluate arbitrary JavaScript, and copy/paste text via clipboard-aware tools
Route tool calls to a specific tab with optional
tabId, or default to the agent's active tabRun multi-agent setups with per-agent ports and WebSocket paths (
/ws/<agent-id>), plus optional shared-secret authSupport hub mode, where one process serves many agents over WebSocket and Streamable HTTP at
/mcp/<agentId>No port conflicts: hard error on port collision instead of killing other processes; localhost-only by default
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., "@Better Browser MCP navigate to github.com/nbiish/betterbrowsermcp/issues"
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.
What's different from @browsermcp/mcp?
|
| |
Port collision behavior | Silently kills the other process ( | Hard error, process exits with clear message |
Multiple agents on one machine | Each MCP process fights for the same port | Each agent runs on its own port — no fighting |
Agent identification | None |
|
WebSocket path |
|
|
Auth | None | Optional |
Recursion bug in | Yes (crashes on every reconnect) | Fixed — explicit |
Workspace monorepo deps | Required | Self-contained, builds from a single |
Bind address | Any | Defaults to |
All browser_navigate, browser_click, browser_snapshot, etc. tool names are identical to upstream — no client changes needed on the LLM side.
Agent self-service: agents can onboard themselves with zero MCP settings changes — see
.agents/skills/bbmcp/SKILL.mdand runnode scripts/bbmcp-connect.mjs --agent <id> --port 9223.
Related MCP server: kilogent-browser
Quick start
Single agent (one process, one browser)
npx @nbiish/betterbrowsermcp@latest
# or, from this checkout:
npm install
npm run build
node dist/index.jsThe server binds port 9009, WebSocket at ws://127.0.0.1:9009/ws/default. The browser extension connects there.
Multi-agent (one process per agent, all sharing one browser)
# Agent "hermes" on port 9009
BROWSER_MCP_AGENT_ID=hermes BROWSER_MCP_PORT=9009 \
npx @nbiish/betterbrowsermcp@latest &
# Agent "omp" on port 9010
BROWSER_MCP_AGENT_ID=omp BROWSER_MCP_PORT=9010 \
npx @nbiish/betterbrowsermcp@latest &
# Agent "codex" on port 9011
BROWSER_MCP_AGENT_ID=codex BROWSER_MCP_PORT=9011 \
npx @nbiish/betterbrowsermcp@latest &Each process binds its own port. They never fight. The browser extension connects to all three WebSocket endpoints and lets the user bind each tab to a specific agent.
Multi-agent setup
The user-facing flow:
Start one MCP process per agent (different ports, different
BROWSER_MCP_AGENT_ID)Configure the browser extension with the list of WS endpoints to monitor (e.g.
ws://127.0.0.1:9009/ws/hermes,ws://127.0.0.1:9010/ws/omp)For each browser tab, the user clicks the extension icon and picks which agent controls it. The binding persists for that tab until changed or disconnected.
Different tabs can be bound to different agents — the same browser serves many agents concurrently.
The MCP processes don't know about each other. The browser extension is the multiplexer that knows which agent controls which tab.
Multi-tab per agent (v0.3.0+)
A single agent can have multiple browser tabs bound to it, so a Hermes-style agent can drive Stripe in one tab and the inference provider dashboard in another, all from one MCP process. The LLM picks which tab to act on.
Tools for multi-tab work
Tool | Purpose |
| List all bound tabs with their tabId, label, URL, active marker |
| Open a new tab and bind it (optional |
| Close a bound tab |
| Set a human-readable label on a tab |
| Switch which tab unspecific tool calls route to |
Every existing browser_* tool also accepts an optional tabId parameter. If omitted, the call routes to the agent's active tab.
Example: driving Stripe + OpenAI console from one agent
LLM: "I need to set up a Stripe webhook. Let me check what tabs are bound."
-> browser_list_tabs
-> response:
tabId=12345 label="Stripe dashboard" url=https://dashboard.stripe.com
tabId=67890 label="OpenAI console" url=https://platform.openai.com ← ACTIVE
LLM: "I should focus on Stripe first."
-> browser_set_active_tab(tabId=12345)
LLM: "Let me take a snapshot of the Stripe dashboard."
-> browser_snapshot
-> response: full ARIA tree, refs like e1, e2, ... for the Stripe dashboard
LLM: "Click 'Webhooks'."
-> browser_click(element="Webhooks link in sidebar", ref="e14")
LLM: "Now switch to the OpenAI console and grab the API key."
-> browser_set_active_tab(tabId=67890)
-> browser_snapshot
-> browser_click(element="API keys", ref="e7")Multi-tab vs. multi-agent
Multi-agent = multiple MCP processes, one per agent (e.g. Hermes + OMP + Codex), each with its own port and WS endpoint
Multi-tab = within ONE agent's MCP process, multiple browser tabs are bound, with per-tab labels and an "active" tab for unspecific calls
The two compose: spawn N MCP processes (multi-agent), each connects to the same browser with M tabs (multi-tab), and the LLM picks which (agent, tab) pair to drive for each tool call.
Parallel-tabs tools (v0.9.0+)
Tool | Purpose |
| Adopt an existing tab into the agent's bound set (no open, no focus) |
| Release a tab without closing it |
| Advisory per-tab lock ( |
| Release the advisory lock |
| Resolve when the tab's content script answers a readiness ping (default 10s) |
| Drain buffered |
browser_open_tab gains makeActive (default false) — background tabs by default; pass true only when you actually want focus. When driving multiple tabs, pass explicit tabId on every call.
Hub mode (v0.9.0+)
One process can now serve MANY agents:
BROWSER_MCP_HUB=true BROWSER_MCP_PORT=9009 node dist/index.js
# extension connects to ws://localhost:9009/ws/<anyAgentId>
# MCP clients POST JSON-RPC to http://localhost:9009/mcp/<agentId>
curl http://localhost:9009/
# {"mode":"hub",...,"agents":[{"agentId":"hermes","connected":true,"boundTabs":2}, ...]}Any
/ws/<agentId>is accepted; the per-agent Context is lazily created on first connect. The auth handshake ({type:"auth", token}) is enforced per Context, exactly like single-agent mode.MCP is also mounted over Streamable HTTP on the same HTTP server:
POST /mcp/<agentId>(stateless JSON-RPC; each request dispatches against that agent's Context).Security: on a non-loopback
BROWSER_MCP_BIND,/mcprequiresAuthorization: Bearer $BROWSER_MCP_AUTH_TOKEN(401 otherwise, fail closed — including when no token is configured).stdio is disabled in hub mode; the process is a daemon (SIGINT/SIGTERM to stop). Single-agent mode (default) is unchanged and byte-compatible.
Environment variables
Var | Default | Description |
|
| Agent identifier. Used in the WS path ( |
|
| WebSocket port to bind. Use different ports for different agents. |
|
| Bind address. Loopback only by default — a non-loopback value requires |
| (unset) | Optional shared secret. If set, the extension must send |
|
| Path prefix for the WS endpoint. Default |
|
| Hub mode (v0.9.0+). |
| (auto) | Override for the |
Browser extension
Better Browser MCP is server-side only. The browser extension that talks to it is a fork of the upstream @browsermcp extension with two changes:
Configurable WS endpoints — instead of a hard-coded
ws://localhost:9009, the extension popup lets the user add/remove WS endpoints to monitor. Each is identified by agent ID.Per-tab agent binding — when the user clicks the extension icon on a tab, they see a list of currently-connected agents (i.e. which WS endpoints are open and which tabs they're bound to). Picking one binds the current tab to that agent until changed or disconnected.
The forked extension is built separately and lives in nbiish/betterbrowsermcp-extension (forthcoming).
Until that's ready, you can patch the upstream extension to:
Read WS endpoints from a config (instead of hardcoded
localhost:9009)Show a tab-binding UI in the popup
Why this exists
The original @browsermcp/mcp@0.1.3 has two design flaws that cause constant pain in multi-agent setups:
1. killProcessOnPort on startup
Every time the server starts, it runs lsof -ti:9009 | xargs kill -9 before binding. This was meant to free the port from a stale previous instance, but in a multi-agent world it means every agent's MCP process murders every other agent's MCP process on startup. The result: keepalive failures every ~90s, ClosedResourceError on every tool call, weeks of debugging.
Better Browser MCP removed this. Port collision is now a hard error with a clear message: which port, which env var to change, and how to investigate (lsof -ti:<port> | xargs ps -p).
2. Single WebSocket per process, no agent awareness
The upstream server has a single Context object holding the one WebSocket. There's no concept of "I'm agent X, please route my tool calls to my tab". The result: in a multi-agent setup, only one agent can have a tab connected at a time, and the others fail with "No connection to browser extension".
Better Browser MCP gives each MCP process an explicit BROWSER_MCP_AGENT_ID. The WebSocket is served at /ws/<agentId>. The browser extension binds tabs to specific agent IDs. Each agent gets its own dedicated tab.
3. Recursion bug in server.close()
The upstream dist/index.js has server.close = async () => { await server.close(); ... } — it calls itself recursively, blowing the stack on every reconnect with RangeError: Maximum call stack size exceeded.
Better Browser MCP fixes this with explicit __origClose binding.
Development
# Install deps
npm install
# Typecheck
npm run typecheck
# Build (ESM via tsup)
npm run build
# Test (manual)
BROWSER_MCP_AGENT_ID=hermes BROWSER_MCP_PORT=9099 \
npm start
# in another shell:
curl http://127.0.0.1:9099/
# {"name":"Better Browser MCP (agent: hermes)","bind":"127.0.0.1","port":9099, ...}Project structure
src/
config.ts env var resolution, WS URL helpers, hub/loopback config
context.ts per-agent Context (one WebSocket = one agent's tabs)
registry.ts AgentRegistry (agentId → Context, hub mode)
events.ts bounded per-agent tab-event ring buffer (cap 200)
messaging.ts WS message protocol (inlined from upstream)
server.ts MCP server, tool routing (shared by both transports)
toolset.ts the full tool list (stdio + HTTP MCP)
http.ts MCP over Streamable HTTP at /mcp(/<agentId>)
tools/ tool implementations (navigate, click, etc.)
utils.ts helpers (wait, port check)
ws.ts WebSocket server + hub routing + health JSON
index.ts entry point (single-agent stdio / hub daemon)
types.ts Zod schemas (inlined from upstream's monorepo)Patched bugs from upstream
server.close()recursion (src/server.ts: increateServerWithTools): captureoriginalClosebefore overridekillProcessOnPortmurder (removed entirely — src/utils.ts: onlyisPortInUseremains)Workspace monorepo deps (inlined into single-package repo)
Credits
Better Browser MCP is a fork of browsermcp/mcp with the multi-agent fixes needed for the Hermes + OMP + Codex multi-agent workflow. Originally adapted from Microsoft's Playwright MCP server.
By Nbiish — first repo, but probably not the last.
Available Tools
23 toolsbrowser_clickC
Perform click on a web page
Inputs: ref (required), element (required), tabId.
browser_close_tabA
Close a browser tab previously bound to this agent. The tab's binding is automatically removed.
Inputs: tabId (required).
browser_copy_to_clipboardA
Click a 'Click to copy' button on the page and return the value the page wrote to the clipboard. Use this for Stripe's publishable key / secret key copy buttons, GitHub PAT copy buttons, AWS access key copy buttons, and any other 'click to copy' UI patterns. The content script patches navigator.clipboard.writeText to capture the value as it is written, then returns it to the LLM.
The returned value is plain text (the literal content of the copy button). For secret material, the LLM should pipe it to the user's PQC secrets store (e.g. via the pqc-secrets CLI) rather than persisting it in chat history or unencrypted state.
Inputs: ref (required), element (required), tabId.
browser_evaluateA
Run an arbitrary JavaScript expression in the page's main frame context. Returns the JSON-serializable result. Escape hatch for when ARIA snapshots cannot introspect something — React/Vue component state, computed styles, custom data attributes, third-party widget internals, hidden DOM the snapshot can't reach. The result must be JSON-serializable and is capped at 10KB. The expression runs in the page's global scope via indirect eval (sees window, document, etc.) and any side effects DO persist — only call this with code you have reviewed.
Inputs: expression (required), tabId.
browser_extract_textA
Extract the textContent of a single element by ref. Cheaper than a full snapshot when you just need the visible text of one element. Returns the text as a plain string with whitespace normalized. Use to grab a heading, a status message, or a single field's value without paying the snapshot cost.
Inputs: ref (required), element (required), tabId.
browser_get_attributeA
Read a single HTML attribute from an element referenced by snapshot ref. Use attr: 'href' to get a link's URL (e.g. extract a Stripe payment link from a share button), attr: 'value' for current input values, attr: 'aria-label' for accessibility text, or any custom data-* attribute. Returns the attribute value as a string, or an empty string if the attribute is not set. Cheaper and more precise than a full snapshot when you need one specific value.
Inputs: ref (required), attr (required), element (required), tabId.
browser_get_console_logsB
Get the console logs from the browser
Inputs: tabId.
browser_go_backB
Go back to the previous page
Inputs: tabId.
browser_go_forwardC
Go forward to the next page
Inputs: tabId.
browser_hoverB
Hover over element on page
Inputs: ref (required), element (required), tabId.
browser_list_tabsA
List all browser tabs bound to this agent. Returns tabId, label, URL, title, and which one is the agent's active tab. Use this to discover what's available before issuing tool calls that need a specific tab.
Inputs: no named parameters.
browser_open_tabA
Open a new browser tab and bind it to this agent. Optionally provide a URL (will navigate after open) and a human-readable label (the LLM uses the label to refer to the tab in subsequent calls). The new tab is set as the agent's active tab.
Inputs: url, label.
browser_paste_textA
Paste text into a focused element on the page. If ref is provided, that element is focused first; otherwise the currently focused element receives the paste. Counterpart to browser_copy_to_clipboard — copy a value with the copy tool, then paste it elsewhere. Dispatches a synthetic paste event (clipboardData.setData + 'insertFromPaste') so React/Vue/Angular controlled inputs accept the value. Use for Stripe webhook secret fields, generated API key fields, or any text you need to push into a form.
Inputs: text (required), ref, tabId, element.
browser_press_keyC
Press a key on the keyboard
Inputs: key (required), tabId.
browser_rename_tabA
Set a human-readable label on a bound tab. The label is what the LLM uses to refer to the tab in conversation and in browser_list_tabs output.
Inputs: label (required), tabId (required).
browser_screenshotB
Take a screenshot of the current page
Inputs: tabId.
browser_select_optionC
Select an option in a dropdown
Inputs: ref (required), values (required), element (required), tabId.
browser_set_active_tabA
Set which bound tab is the agent's active tab. Tool calls that don't specify a tabId route to the active tab. Use this to switch the agent's focus between bound tabs.
Inputs: tabId (required).
browser_snapshotA
Capture accessibility snapshot of the current page. Use this for getting references to elements to interact with. The snapshot also lists all bound tabs so the LLM can pick a different tab via tabId on subsequent calls.
Inputs: tabId.
browser_typeC
Type text into editable element
Inputs: ref (required), text (required), submit (required), element (required), +1 more.
browser_waitB
Wait for a specified time in seconds
Inputs: time (required).
browser_wait_for_textA
Wait for a specific text to appear anywhere in the page DOM (case-insensitive substring match). Polls every 500ms until the text is found or the timeout elapses. Replaces blind browser_wait(time) calls. Returns a fresh ARIA snapshot once the text appears. Use after clicking a submit button, or to wait for a Stripe dashboard to finish loading. On timeout, returns an error with the current page text length so you can diagnose what is or isn't rendering.
Inputs: text (required), tabId, timeout.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 23 tools
Each tool targets a distinct browser action or data type: navigation, tab management, user interaction, page introspection, and utility functions are clearly separated. Even the three read-ish tools (snapshot, extract_text, get_attribute) are distinguished by use case: full snapshot with refs vs. targeted text/attribute retrieval.
All 23 tools share the consistent 'browser_' prefix and use snake_case throughout. While some names are single verbs (navigate, click, wait) and others are verb phrases (go_back, wait_for_text, copy_to_clipboard), the pattern is uniform and predictable, with no mixed conventions or style breaks.
At 23 tools, the count falls into the 'heavy' range (16–25) per the rubric. Each tool is legitimately distinct and useful for browser automation, but the sheer number may add selection overhead and suggests the surface could potentially be consolidated (e.g., merging extract_text and get_attribute into a single read tool).
The tool surface covers the full browser automation lifecycle: tab management (open, close, list, set active, rename), navigation (navigate, back, forward), interaction (click, hover, type, select, press key), page inspection (snapshot, screenshot, attributes, text), waiting (wait, wait_for_text), console logs, clipboard, and arbitrary JS evaluation. There are no obvious dead ends for common browser workflows.
Maintenance
Related MCP Connectors
Official Browserless MCP server: browser automation, scraping, screenshots and PDFs for AI agents.
Remote MCP server exposing your Node Command fleet (files, shell, processes, browser) to AI hosts.
MCP server to assist with JxBrowser development.
Host any HTTP API as a standard MCP server; auto-distribute to 12+ mainstream agent platforms.
Related MCP Servers
- AlicenseAqualityCmaintenanceAn MCP server that runs concurrent, session-isolated Playwright browser contexts, so many agents can each drive their own browser at the same time without colliding.2311 npm5MIT
- AlicenseNot gradedqualityBmaintenanceMCP server that lets agents drive your real Chrome browser with existing logins and sessions via an outbound-only WebSocket extension. It exposes Playwright-compatible browser tools for navigation, clicking, typing, and snapshots.1Apache 2.0
- FlicenseAqualityBmaintenanceAn MCP server that enables remote observation and control of a Chromium browser via a WebSocket relay, supporting DOM, CDP, and native OS automation for local or LAN-based AI agents.8-
- AlicenseNot gradedqualityAmaintenanceThis server enables MCP clients and AI agents to perform browser automation with Playwright-compatible tools, persistent profiles, extensions, proxy support, and humanized interactions. It can be run via npm or Docker over stdio or Streamable HTTP for web research, automation, testing, and regional QA.280 npmMIT