Skip to main content
Glama
satura-browser-mcp

satura-browser-mcp

Official
by saturadev
README.md
# satura-browser-mcp

MCP server for Claude Code that drives a **pool of named, persistent Google Chrome browsers**.
Each pool browser is a real Google Chrome process with its own profile under
`~/.satura-browser/pool/<name>/profile`, so logins, cookies and tabs survive between sessions.

- **One tool, `browser_run`:** the agent writes JavaScript that runs against the leased Chrome
  with the Playwright API (`page`, `context`, `request`) plus a few helpers. One call can do a
  whole step (navigate, fill, click, extract) and return only the data it needs. Playwright is
  `playwright-core@1.63.0`, vendored verbatim at `vendor/playwright/playwright-core` (nothing is
  pulled from npm) and attached to Chrome over CDP; `src/` uses only its public API. The
  snapshot diff comes from BetterWright, copied into `vendor/betterwright/` (MIT).
- **Saved scripts per site:** working code is saved as a parametrised function under the site's
  host and offered back in later responses on that site, so a repeated extraction or flow runs
  the cached code instead of being written again.
- **Never takes focus:** the MCP server launches Chrome without a startup window and opens its
  window in the background, `newTab()` and `context.newPage()` open background tabs (a new
  foreground tab would restore a minimized window and activate Chrome), and `tab(i)` only changes
  the tab the code works on; nothing calls `bringToFront`. Background tabs still take input, screenshots and dialogs. Only
  `satura-browser login` brings its window forward, since you sign in there.
- **Pool:** each MCP session leases one browser exclusively. The lease is released when the
  session exits; a crashed session's lease is ignored (dead pid).
- **State is copied once, at creation.** A new browser is a copy of an existing profile
  (default: the most recently used one), plus a one-time copy of cookies from the running
  source because Chrome writes cookies to disk lazily. After that nothing syncs.
- **Real Chrome, no automation flags:** launched via LaunchServices with
  `--disable-blink-features=AutomationControlled`, so `navigator.webdriver` is false and
  Google sign-in works.
- **Dock:** every browser gets its own Dock icon with its name and color
  (`Browser.setDockTile`). The hover label stays "Google Chrome"; it comes from the signed app
  bundle, and a renamed copy of the bundle would break Keychain access (logins) and could
  redirect Chrome's auto-updates.

## Agent workflow

The server exposes a single tool:

```
browser_run { code: string, timeout?: seconds (default 60, max 600), changes?: boolean }
```

`code` is the body of an async function; `return` gives a value back. Until a browser is chosen,
`page` throws and the server instructions tell the agent to run `return await pool.status()`,
show you the browsers, ask which one to open, then run `await pool.use(name)`. A name that does
not exist creates a new browser.

| Global | |
|---|---|
| `page`, `context`, `request` | Playwright `Page` of the current tab, its `BrowserContext`, and an HTTP client sharing the browser's cookies |
| `pool.status()`, `pool.use(name?, {cloneFrom, steal})`, `pool.release()` | the pool (`cloneFrom: 'none'` = empty profile) |
| `snap({root, depth, boxes})`, `ref('e12')` | aria snapshot with refs; Locator for a ref |
| `read(target?)` | visible text of the page or an element as compact markdown (headings, links, lists, tables, form values) |
| `shot(target?, {fullPage, save})` | screenshot attached to the response (JPEG, CSS pixels) |
| `tabs()`, `tab(i)`, `newTab(url?)` | tabs; `tab(i)` makes tab i current |
| `settle(ms?)` | wait until the tab's network is quiet |
| `net.list(filter?)`, `net.body(id)`, `net.clear()` | recorded requests of the tab (document/fetch/xhr by default) and their bodies |
| `logs({level})` | console messages and page errors of the tab |
| `save(name, data)` | write data to `.satura-browser/data/` in the project, returns the path |
| `scripts.list/run/save/get/remove` | saved per-site scripts (below) |
| `state` | object kept between calls (top-level variables are not) |
| `console.log`, `help(topic)` | output in the response; longer docs (`scripts`, `net`, `examples`) |

The code runs in a `node:vm` context that holds only these globals (no `require`, `process` or
`fs`), with a timeout. That keeps a script from breaking the server; it is not a security
boundary, same as Playwright MCP's `browser_run_code_unsafe`.

### Response

Every response is a few short sections, each present only when it has content:

- `### Browser`: the pool picked or created a browser, or relaunched a crashed one.
- `### Error`: the message plus the line of the agent's code (or saved script) that threw.
  Console output and page state up to the error are still reported.
- `### Result` and `### Console`: the returned value (JSON, arrays one item per line) and logs.
  Over 20,000 / 8,000 characters, the full text goes to `.satura-browser/output/` and the
  response keeps the start and the path.
- `### Events`: dialogs answered automatically (alerts and `beforeunload` accepted, confirms and
  prompts dismissed, unless the code registered its own `dialog` handler), downloads saved to
  `.satura-browser/downloads/`, new tabs and popups, new console errors.
- `### Page`: the current tab and the other tabs.
- `### Changes`: after the network settles, the aria snapshot is compared with the previous one
  of the same tab (diff from [BetterWright](https://github.com/BetterWright/betterwright)) and only
  the `+`/`-` lines are shown. Playwright keeps a ref for as long as its element keeps its role
  and name, so refs from earlier responses stay valid. A tab seen for the first time, or one that
  changed almost entirely, is shown in full as `### Snapshot` when it is under 4,000 characters,
  otherwise only its size is given and the agent calls `snap()` or `read()`.
- `### Saved scripts for <host>`: shown when the current site has saved scripts and this session
  has not seen that list yet.

`ref()` fails at once for a ref that is not in the latest snapshot of the tab instead of waiting
for the action timeout. Actions wait up to 10 s, navigations 30 s.

### Saved scripts

```js
await scripts.save('orders', async ({ from = 1 }) => {
  await page.goto(`https://shop.example/orders?page=${from}`);
  return page.locator('tr.order').evaluateAll(rows => rows.map(r => r.innerText));
}, 'Orders table -> row texts; args {from}');

return await scripts.run('orders', { from: 2 });
```

- A script is a self-contained `async (args) => result` function that runs with the same
  globals, so scripts can call `scripts.run` too.
- It is stored under the host of the current tab (`www.` dropped) in
  `~/.satura-browser/scripts/<host>/<name>.js`, with `<name>.json` holding the description and
  run statistics. `{site: '*'}` stores it for any site; `scripts.run` falls back to those.
  Scripts are shared by every pool browser and project.
- A failing script is reported with the line inside it, counted, and listed as failing until it
  is saved again under the same name.
- `satura-browser scripts [SITE]` lists them; `satura-browser scripts rm SITE NAME` deletes one.

Every call is also appended to `~/.satura-browser/runs/<date>.jsonl` (code, browser, URL, time,
error), which helps when debugging a session or turning a good sequence into a saved script.

Files the tool writes in the project go to `.satura-browser/`; add it to the project's
`.gitignore`.

## CLI

```
satura-browser status                          every browser: free/busy, running, open tabs
satura-browser create NAME [--from SRC|--empty]
satura-browser rename OLD NEW                  (free browsers only; closes the window)
satura-browser login [URL] [--name NAME]       open a window to sign in by hand
satura-browser stop [NAME...] [--all] [--force]
satura-browser rm NAME [--force]
satura-browser chrome-profiles                 profiles of your everyday Chrome
satura-browser import-chrome PROFILE [--name NAME]
satura-browser scripts [SITE]                  saved per-site scripts
satura-browser scripts rm SITE NAME
satura-browser doctor
```

## Setup

macOS or Linux with Google Chrome installed, Node.js 20 or newer:

```
git clone https://github.com/saturadev/satura-browser-mcp.git
cd satura-browser-mcp
npm install && npm run build && npm link
claude mcp add satura-browser -s user -- node "$PWD/dist/server.js"
```

Server flags: `--browser NAME` pins the session to one browser (no question asked),
`--headless`. Config: `~/.satura-browser/config.json` with `chromePath`, `maxInstances`
(default 8), `headless`, `extraArgs`. Env: `SATURA_BROWSER_HOME`, `SATURA_BROWSER_NAME`,
`SATURA_BROWSER_HEADLESS`, `SATURA_BROWSER_CHROME`.

Security: each running browser listens on a random 127.0.0.1 DevTools port; any local process
can drive it, same as Playwright MCP with a persistent profile. `browser_run` executes the
agent's JavaScript in the server process (see above).

## Tests

`npm test` runs end-to-end tests against real Chrome (headless; `SATURA_TEST_HEADED=1` for windows).
Run them after updating `vendor/playwright/` too: they check the snapshot and ref format the
tool relies on.

## License

MIT, see [LICENSE](LICENSE). Third-party code in `vendor/` keeps its own license:
`vendor/playwright/playwright-core` and the modules in `vendor/playwright/copied` come from
Playwright (Apache-2.0, see `vendor/playwright/playwright-core/LICENSE` and `NOTICE`), and
`vendor/betterwright` comes from BetterWright (MIT, see `vendor/betterwright/LICENSE`).