mare-browser-mcp
# mare-browser-mcp
A lean, LLM-first browser automation MCP server. Gives Claude (or any MCP client) a real Chromium browser to navigate, interact with, and debug web apps — without the overhead of raw Playwright APIs.
Built with [Playwright](https://playwright.dev) + [MCP SDK](https://github.com/modelcontextprotocol/typescript-sdk). Isolated mode keeps one browser per MCP server; existing-browser commands from multiple MCP clients are serialized through one local bridge.
Mare can use either its own isolated Playwright browser or your already-running,
signed-in Chromium through the bundled **Mare Browser Bridge** extension.
**Free to use.** If it saves you time, [buy me a coffee](https://buymeacoffee.com/emadomar) ☕
---
## Install (recommended)
**Prerequisites:** Node.js 18+, pnpm
```bash
git clone https://github.com/emadklenka/mare_browser_mcp
cd mare_browser_mcp
pnpm install
npx playwright install chromium
```
This is the fastest way to run the server — starts instantly with no registry lookups.
---
## Alternative installs
**Global install** — no cloning, still fast:
```bash
pnpm add -g mare-browser-mcp
npx playwright install chromium
```
---
## Register with Claude Code
If you cloned the repo, the setup script does it for you:
```bash
pnpm run setup
```
That's it. The script detects the correct path automatically and registers the MCP with Claude Code. Restart Claude Code and the browser tools are ready.
**Manual config** — add to `~/.claude.json` under `mcpServers`:
```json
{
"mcpServers": {
"mare-browser": {
"command": "node",
"args": ["/absolute/path/to/mare_browser_mcp/src/index.js"],
"env": { "HEADLESS": "false" }
}
}
}
```
If installed globally:
```json
{
"mcpServers": {
"mare-browser": {
"command": "mare-browser-mcp",
"env": { "HEADLESS": "false" }
}
}
}
```
---
## Register with OpenCode
Add this to `~/.config/opencode/opencode.json` (global) or `opencode.json` (project root):
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"mare_browser_mcp": {
"type": "local",
"command": [
"node",
"/absolute/path/to/mare_browser_mcp/src/index.js"
]
}
}
}
```
If installed globally:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"mare_browser_mcp": {
"type": "local",
"command": ["mare-browser-mcp"]
}
}
}
```
---
## Tools
### Choose a browser session
Mare defaults to its original isolated Playwright browser. To use an existing
Chrome/Chromium session instead:
1. Open `chrome://extensions`, enable **Developer mode**, and click **Load unpacked**.
2. Select this repository's `extension` directory.
3. Open **Mare Browser Bridge** from the toolbar and enable browser control.
4. Attach the current tab with `browser_session({ mode: "existing", tab: "current" })`,
or create a dedicated AI tab with `browser_session({ mode: "existing", tab: "new" })`.
Switch back at any time with `browser_session({ mode: "isolated" })`. Calling
`browser_session({})` reports both backends without switching. The isolated
browser is preserved while the extension is selected, so switching back does
not discard its current page.
An existing-browser session is pinned to one exact Chrome tab. Switching to a
different tab yourself does not move the agent. Mare marks its controlled tab
with a teal AI favicon and an `AI` extension badge. If the tab is closed or
navigated manually, Mare stops with a clear error instead of silently taking
over another tab. Set `recovery: "reopen"` when attaching if Mare should recreate
a closed tab at its last URL; the safer default is `recovery: "ask"`.
Multiple LLM/MCP sessions can share the bridge safely by attaching different
tabs. Give each a recognizable label, for example:
```text
browser_session({ mode: "existing", tab: "new", label: "Research", recovery: "ask" })
browser_session({ mode: "existing", tab: "current", label: "QA", recovery: "reopen" })
```
Each tab is pinned to exactly one session, and another session can no longer
silently steal it with `replace: true`. When two agents want the same tab, Mare
uses a cooperative handoff:
1. The second session calls
`browser_session({ mode: "existing", tab: "current", request_release: true })`.
2. The owner is notified through a `releaseRequested` notice on its next command
results and grants the tab with `browser_session({ detach: true, handoff: true })`.
3. The second session retries the same attach within 2 minutes and receives the
handed-over tab.
Requests and grants expire after 2 minutes. `replace: true` remains as an
escape hatch for stuck sessions: it only works after a handoff grant names you
or once the owning session has been idle for 10+ minutes.
Set `MARE_CLIENT_NAME` in each MCP configuration to
give the session a stable human-readable default label. Release the marker and
ownership with `browser_session({ detach: true })` when that agent is finished.
For a dedicated tab created with `tab: "new"`, use
`browser_session({ detach: true, close_tab: true })` to release and close it
(`handoff: true` takes precedence over closing when a release request is
pending). Mare refuses to close a user-owned tab attached with `tab: "current"`.
All normal `browser_*` tools route through the selected backend. Existing-browser
mode operates only the pinned tab and preserves the browser itself on
`browser_restart` (the active tab is reloaded instead). Chrome displays its
standard debugging notice while debug capture, full-page screenshots, video,
or device emulation uses the extension's debugger permission.
### `browser_navigate(url, clear_logs?)`
Navigate to a URL. Pass `clear_logs: true` when starting a new task to wipe stale console/network/dialog history.
### `browser_act(commands[])`
Run a sequence of actions in one call. Supported actions:
| action | required params | optional params | what it does |
|---|---|---|---|
| `click` | `selector` | `button` (`left`/`right`/`middle`) | Click an element. Use `button: "right"` for context menus |
| `hover` | `selector` | | Hover over an element — triggers tooltips, dropdown menus, hover states |
| `drag` | `selector` | `target` or `offsetX`/`offsetY` | Drag an element to another element (`target`) or by pixel offset (for resizing, sliders) |
| `clicklink` | `text` | | Click a link/button by its visible text |
| `fill` | `selector`, `value` | | Type into an input (clears first) |
| `select` | `selector`, `value` | | Select a dropdown option |
| `keypress` | `key` | | Press a key (e.g. `Enter`, `Tab`, `Escape`) |
| `waitfor` | `selector` | `timeout` | Wait until element appears |
| `scrollto` | `selector` | | Scroll element into view |
| `wait` | `ms` | | Pause for N milliseconds |
| `clearconsole` | — | | Clear console log buffer |
Every selector-based action also accepts `frame`, using an id returned by
`browser_frames`. Refs captured by a frame-targeted snapshot remember their
frame automatically.
#### Mouse sequence and outcome checks
For legacy widgets that ignore a plain click, use `click_mode: "mouse"` on
`click` or `clicklink`. Existing-browser mode dispatches synthetic
`mousedown`, `mouseup`, and `click` MouseEvents with the target's coordinates,
button state, bubbling, cancellation and composed flags. It focuses a focusable
target unless mousedown is canceled. Right and middle buttons end with
`contextmenu` and `auxclick`, respectively. This is **not trusted physical
input** and does not emulate pointer events, hover, or every native browser
behavior. Isolated mode keeps Playwright's mouse input. The default mode is
unchanged in both backends.
`delay_ms` optionally holds the press for 0–1000 ms (default 0, mouse mode only).
Use a delay only when the widget needs it; no post-click sleep is added.
Mouse mode rejects disabled, hidden, covered and detached targets rather than
silently activating them. A failure after mousedown can still have side effects.
```js
browser_act({ commands: [{
action: "click",
ref: "e9",
click_mode: "mouse",
expect: { selector: "#destination-panel", timeout: 5000 }
}] })
```
`expect` accepts exactly one visible CSS `selector` or literal `url_pattern`
substring, an optional destination `frame` (id/name/URL; defaults to the action
frame), and a 1–30000 ms `timeout`. For a newly created iframe, use a stable
frame name. Choose a destination-specific condition: an already visible element
or matching URL also satisfies the check; this does not prove a transition.
Results distinguish `action_status: "dispatched"` from `outcome: "observed"`.
Without an expectation, the outcome is `not_requested`. An unmet expectation
returns `success: false` and `outcome: "timed_out"` (or `"unavailable"` when the
final probe failed), and stops the remaining batch. Only observations are
retried, never the click. Do not repeat a possibly submitted action automatically.
An interrupted script during full navigation can leave dispatch status unknown;
inspect the destination before deciding what to do next.
Requires Bridge **1.1.2+**. Reload the unpacked extension and restart the MCP
client after updating to expose the new tool parameters. The server rejects
these options with an older loaded Bridge, instead of silently ignoring them.
### `browser_frames()`
List the main document and all nested iframes, including cross-origin frames.
Each result includes an id such as `f0` or `f7`, its URL, name, parent, and
whether it is the main frame. Pass that id to `browser_snapshot`,
`browser_query`, `browser_eval`, or an action command:
```text
browser_frames()
browser_snapshot({ frame: "f7", compact: true })
browser_act({ commands: [{ action: "click", ref: "e1" }] })
browser_eval({ frame: "f7", code: "location.origin" })
```
### `browser_snapshot(max_depth?, compact?, frame?)`
Return a compact accessibility/DOM tree with stable refs. It targets the main
document by default; pass a frame id, exact frame name, or frame URL to inspect
an embedded application directly.
### `browser_debug()`
**Start here when something goes wrong.** Returns in one call:
- Current URL and page title
- Console logs (filterable by type: `error`, `warning`, `log`, `pageerror`)
- Network request metadata with: method, URL, redacted query params, request headers (auth masked), status code, and `duration_ms` timing
- Dialog history (alert/confirm/prompt — auto-accepted, text captured)
Filter with `url_filter`, `method_filter`, `console_types`, or `last_n`.
Request and response bodies are omitted by default. Set `include_bodies: true` only when necessary; credential-like keys are recursively redacted in requests, responses, and query parameters.
Console messages and page errors are also scrubbed for JWTs, bearer values,
tokens, API keys, authorization values, cookies, session ids, and passwords
before they enter Mare's log buffer.
### `browser_query(selector, all?, fields?, visible_only?, limit?, count_only?, frame?)`
Read the DOM without a screenshot. Query any element by CSS selector.
| param | what it does |
|---|---|
| `all` | Return all matching elements (default: first only) |
| `fields` | Pick fields: `text`, `value`, `visible`, `disabled`, `className`, `href`, `innerHTML` |
| `visible_only` | Filter to visible elements only — recommended for broad selectors |
| `limit` | Cap the number of results (e.g. `10`) to prevent huge payloads |
| `count_only` | Just return the count — fast way to check "how many rows?" without fetching data |
### `browser_eval(code, frame?)`
**Escape hatch** for anything the other tools don't cover:
- Read computed styles: `getComputedStyle(el).backgroundColor`
- Append text to inputs without clearing
- Type character-by-character for autocomplete
- Drag-and-drop via manual DOM events
- Call `fetch()` to hit APIs directly
- Read JS app state (`window.__store__`, etc.)
- Check CSS visibility (`display`, `opacity`, `visibility`)
### `browser_scroll(direction?, pixels?, selector?, container?)`
Three modes:
- **Page scroll:** `direction: "down", pixels: 500`
- **Scroll into view:** `selector: ".my-element"`
- **Scroll within a container:** `container: ".ag-body-viewport", direction: "down", pixels: 300` — for scrollable divs, grid viewports, chat panels
### `browser_wait_for_network(url_pattern?, method?, timeout?)`
Wait for a specific network response after triggering an action — smarter than guessing with `wait`.
### `browser_screenshot()`
Returns a PNG screenshot. **Use as a last resort** — prefer `browser_debug` and `browser_query` first.
### `browser_save_screenshot(filename?, full_page?, format?, hide_recording_pointer?)`
Save a screenshot as an artifact under the OS temp directory and return its absolute path, MIME type, byte size, physical pixel dimensions, CSS viewport, device-pixel ratio, URL, and page title. This is the preferred screenshot tool for QA evidence, documentation, and marketing assets because it avoids returning a large base64 payload.
Mare hides its recording pointer before saved screenshots by default, preventing a completed action clip from contaminating later clean or target stills. Set `hide_recording_pointer: false` only when intentionally documenting the pointer itself.
```text
browser_save_screenshot({ filename: "candidate-grid", full_page: true, format: "png" })
// -> { ok: true, path: "/tmp/mare-browser-mcp/candidate-grid.png", ... }
```
Set `CAPTURE_DIR` to override the default temp artifact directory.
### `browser_video(action, filename?, format?, ...)`
Record a precise Playwright screencast. `action` is `start`, `stop`, `status`, or `capture_click`; `format` is `webm` (default) or `mp4`. Screencast start and stop operate on the live page without recreating the browser context, so in-memory application state is preserved. While recording, click actions show a translucent yellow pointer and pulse. Mare now hides that pointer automatically after every stop.
The default `capture_scale: "device"` records at device-pixel dimensions so video and ordinary viewport PNGs share the same native canvas on high-DPI displays. Use `capture_scale: "css"` for a smaller CSS-pixel recording. Start/status/stop responses report the Mare version, CSS viewport, device-pixel ratio, capture scale, and output size.
```text
browser_video({ action: "start", filename: "candidate-walkthrough", format: "mp4" })
// perform browser actions
browser_video({ action: "stop" })
// -> { ok: true, path: "/tmp/mare-browser-mcp/candidate-walkthrough.mp4", mode: "screencast", ... }
```
For short product-storyboard actions, prefer the atomic form. It performs the start, one click, optional URL wait, short click-pulse tail, and stop inside one MCP call, avoiding static padding caused by model/tool round trips:
```text
browser_video({
action: "capture_click",
filename: "open-candidate",
format: "mp4",
selector: "[data-testid='candidate-link']",
wait_for_url: "/cnd/",
timeout: 2500,
post_click_ms: 450
})
```
`capture_click` returns the source and destination URLs, action success, URL-match result, finalized artifact metadata, and pointer-cleanup result. MP4 output is automatically transcoded to high-quality H.264 and requires `ffmpeg` on `PATH`.
On Playwright versions older than 1.59, Mare retains the previous context-level WebM recorder as a compatibility fallback. Stop an active recording before calling `browser_restart`.
### `browser_upload(selector, files[])`
Upload files to a file input element.
### `browser_restart(url?)`
Kill the browser and start fresh. Clears all logs. Optionally navigate to a URL after restart.
### `browser_emulate_device(device, orientation?, custom?)`
Switch the browser into a device profile for responsive QA. Emulation persists across navigations until you swap devices or call `browser_restart`.
**Presets (natural portrait viewport):**
- `iphone-15-pro-max` (430×932), `iphone-15-pro` (393×852), `iphone-15` (393×852), `iphone-se` (375×667)
- `galaxy-s24` (360×800)
- `ipad-pro-13` (1024×1366), `ipad-pro-11` (834×1194), `ipad-mini` (768×1024)
- `galaxy-tab-s9` (800×1280)
- `desktop-chrome` (1280×800) — resets to desktop
- `custom` — requires `custom.userAgent` + `custom.viewport.{width, height}`
Swapping devices recreates the browser context, so cookies and localStorage are lost and auth'd pages may land on login. `innerWidth: 980` on a mobile emulation viewing a page without `<meta name="viewport">` is Chrome's legacy fallback, not a bug — `pointer_coarse`, `hasTouch`, and `userAgent` are the authoritative signals. `browser_debug` surfaces the active emulation under an `emulation` field.
---
## Example workflow
```
1. browser_navigate("https://myapp.com", clear_logs: true)
2. browser_act([
{ action: "fill", selector: "#email", value: "user@example.com" },
{ action: "fill", selector: "#password", value: "secret" },
{ action: "click", selector: "button[type=submit]" }
])
3. browser_wait_for_network({ url_pattern: "/api/session", method: "POST" })
4. browser_debug({ console_types: ["error"] }) <- check for login errors
5. browser_query(".dashboard-title") <- confirm we're logged in
```
### Hover + tooltip example
```
1. browser_act([{ action: "hover", selector: ".info-icon" }])
2. browser_query(".tooltip", { fields: ["text", "visible"] })
```
### Drag-and-drop example
```
// Reorder columns
browser_act([{ action: "drag", selector: ".col-name", target: ".col-age" }])
// Resize a column by 100px
browser_act([{ action: "drag", selector: ".resize-handle", offsetX: 100, offsetY: 0 }])
```
### Right-click context menu
```
1. browser_act([{ action: "click", selector: ".grid-row", button: "right" }])
2. browser_query(".context-menu-item", { all: true, fields: ["text"] })
```
### Scroll inside a container
```
browser_scroll({ container: ".ag-body-viewport", direction: "down", pixels: 500 })
```
### Count elements quickly
```
browser_query({ selector: ".ag-row", count_only: true })
// -> { selector: ".ag-row", count: 47 }
```
### Emulate a mobile device
```
1. browser_emulate_device({ device: "iphone-15-pro-max" })
2. browser_navigate({ url: "https://www.youtube.com" })
// redirects to m.youtube.com because of the iPhone UA
3. browser_screenshot() // mobile layout
4. browser_emulate_device({ device: "ipad-pro-13", orientation: "landscape" })
5. browser_emulate_device({ device: "desktop-chrome" }) // reset
```
---
## Environment
| Variable | Default | Description |
|---|---|---|
| `HEADLESS` | `false` | Run browser headless (`true`) or visible (`false`) |
| `REAL_CHROME` | `false` | Use your installed Chrome instead of Playwright's Chromium |
| `CHROME_PROFILE` | `Default` | Chrome profile name (when `REAL_CHROME=true`) |
| `CAPTURE_DIR` | OS temp + `mare-browser-mcp` | Screenshot and video artifact directory |
| `BROWSER_MODE` | `isolated` | Startup backend: `isolated` or `existing` |
| `MARE_BRIDGE_PORT` | `37431` | Shared localhost extension-daemon port (the unpacked extension scans `37431–37440`) |
| `MARE_CLIENT_NAME` | `Mare <pid>` | Default label used for this MCP session's pinned tab |
The shared extension bridge listens only on `127.0.0.1`; the extension scans ports `37431–37440`.
It accepts browser clients only from a `chrome-extension://` origin. No cookies,
passwords, or browser profile data are copied into Mare; commands execute in the
selected existing tab.
The browser launches lazily — it won't open until the first tool call.
---
## License
MIT — free to use, modify, and distribute.
If this project helps you, [buy me a coffee](https://buymeacoffee.com/emadomar) ☕
TDQS
Scored across 14 tools
Each tool has a clearly distinct purpose: navigation, action execution, debugging, DOM querying, snapshotting, waiting, screenshotting, JS eval, scrolling, restarting, uploading, network waiting, and device emulation. Even the inspection tools (debug, query, snapshot, eval) are well separated by what they return and when to use them.
All 14 tools follow the consistent pattern of 'browser_' prefix followed by a lowercase verb (navigate, act, debug, query, fetch, snapshot, wait_for_url, screenshot, eval, scroll, restart, upload, wait_for_network, emulate_device). No mixing of camelCase or inconsistent verb styles.
14 tools is within the ideal 3-15 range and each tool serves a distinct aspect of browser automation—navigation, interaction, inspection, waiting, debugging, device emulation, and file upload. The count is well-scoped for a comprehensive browser control server without being bloated.
The server covers the full lifecycle of browser interaction: navigate, act, inspect, wait, debug, screenshot, and emulate. Minor gaps exist such as no explicit dialog acceptance (alert/confirm/prompt) or tab management, but agents can work around these with browser_eval in most cases, so they are not critical dead ends.