Browser Controller
by compnew2006
README.md
<p align="center">
<img src="https://cdn.jsdelivr.net/gh/compnew2006/browser-controller@51be0c2338b3ade9ebef1587b872cfa03dc45701/assets/logo.png" alt="Browser Controller" width="100" height="100" />
</p>
<h1 align="center">Browser Controller</h1>
<p align="center">
<strong>The missing piece in AI coding: your agent can now see your REAL browser.</strong>
</p>
<p align="center">
<a href="https://github.com/compnew2006/browser-controller/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/compnew2006/browser-controller/ci.yml?branch=main&label=CI&style=flat-square" alt="CI" /></a>
<a href="https://github.com/compnew2006/browser-controller/releases"><img src="https://img.shields.io/badge/version-2.2.0-blue?style=flat-square" alt="v2.2.0" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-yellow?style=flat-square" alt="License: MIT" /></a>
<img src="https://img.shields.io/badge/node-%E2%89%A520-339933?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node >= 20" />
<img src="https://img.shields.io/badge/TypeScript-strict-blue?style=flat-square" alt="TypeScript strict" />
<img src="https://img.shields.io/badge/tests-291%20passing-22c55e?style=flat-square" alt="291 tests" />
</p>
---
## What this project solves
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.
## Key capabilities
- **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 tab` and disappears when the action finishes. Locking a tab keeps a plain frame for the lock's lifetime.
- **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, and `find`/`click_text` walk every frame.
- **Open-dialog rescue.** A native `alert`/`confirm`/`prompt` freezes the page's JS thread — `browser_handle_dialog` dismisses it out-of-band via CDP, no page JS needed, which also un-blocks every other tool on that tab. `browser_tabs close/focus` always work, even on a frozen tab.
- **Authenticated local connection.** Token + one-time enrollment secret, so no other local process can silently drive your browser. 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.
- **No debugger banner.** `browser_evaluate` runs in the page's MAIN world via `chrome.scripting` — no yellow "this tab is being debugged" banner, and real values return across the MV3 world boundary.
- **Honest errors.** Every tool failure reaches your agent as a real `isError` result with the full payload — no "success" responses hiding failures mid-workflow.
---
## How it works
Three pieces, all on your machine. Nothing leaves localhost.
```mermaid
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.
---
## Quick Start
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](https://nodejs.org/) and Chrome/Chromium/Edge.
### 1. Clone & build
```bash
git clone https://github.com/compnew2006/browser-controller.git
cd browser-controller
npm install
npm run build # compiles TypeScript → mcp-server/dist/
```
### 2. Load the Chrome extension
1. Open `chrome://extensions` and enable **Developer mode** (toggle in the top right)
2. Click **Load unpacked** and select the `extension/` folder from the cloned repo
3. Pin the Browser Controller icon to your toolbar
Gray dot = waiting for the daemon. Green = connected.
### 3. Add the MCP server to your client
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`):
```json
{
"mcpServers": {
"browser-controller": {
"command": "node",
"args": ["/path/to/browser-controller/mcp-server/dist/index.js"]
}
}
}
```
<details>
<summary><b>Name your agent (shows in the popup)</b></summary>
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:
```json
{
"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.
</details>
### 4. Pair the extension with the daemon
The daemon uses two secrets, both generated on first run into `~/.browser-controller/` (Windows: `%USERPROFILE%\.browser-controller\`). Start it once by asking your agent to "list my browser tabs", then:
1. Read the secrets:
```bash
cat ~/.browser-controller/enrollment.json # one-time pairing secret
cat ~/.browser-controller/token.json # WebSocket auth token
```
(The enrollment secret is also printed to the MCP client's log on first run.)
2. Click the extension icon → **Settings** tab → paste the **Enrollment Secret** and the **Auth Token** (leave the port at `7225` unless you changed `WS_PORT`).
Green dot = you're connected. Your agent can now see your browser.
> 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](SECURITY.md) for the full threat model.
### Permission and trust boundary
The unpacked extension deliberately requests Chrome's powerful `debugger`, `scripting`, `webRequest`, and `<all_urls>` permissions. 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.
---
## Using it
The model is **tab-first**: the agent always says *which* tab to act on. It never assumes "the active tab."
### Basic workflow
1. **List tabs** to get a `tabId`:
```
browser_tabs { action: "list" }
→ [{ id: 15, url: "...", title: "...", active: true, lockedBy: null }, ...]
```
2. **Snapshot that tab** to see its structure and get element refs:
```
browser_snapshot { tabId: 15 }
→ { tree: [ { ref: "e3", role: "button", name: "Sign in" }, ... ] }
```
Refs are valid **only for this tabId**. If you navigate or the DOM changes, re-snapshot. New elements since the last snapshot are tagged **`isNew: true`** — after an action opens an overlay/dropdown, the agent can focus on just those instead of re-reading the whole tree.
3. **Interact** using the ref and the same tabId:
```
browser_click { tabId: 15, ref: "e3" }
browser_type { tabId: 15, ref: "e5", text: "hello@example.com" }
browser_press_key { tabId: 15, key: "Enter" }
```
If a ref is stale but the element still exists, it's found automatically via a robust selector + text/role scan (response carries `via: "fallback"`). If the element was scrolled away entirely (virtualized feeds), the response carries **`freshRefs: [...]`** with a fresh snapshot inline — retry with one of those new refs in the same step, no separate snapshot needed.
4. **Verify** — snapshot or read text again after the action.
### Safe Observe → Act workflow
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:
```text
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`:
```text
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.
### Multi-agent coordination (two agents, two tabs)
1. Agent A lists tabs, picks tab 10, optionally locks it: `browser_tabs { action: "lock", tabId: 10 }`
2. Agent B lists tabs, picks tab 11, locks it: `browser_tabs { action: "lock", tabId: 11 }`
3. Both work in parallel. Each agent's calls serialize against its own tab; the two tabs never interfere.
4. When done: `browser_tabs { action: "unlock", tabId: 10 }`.
### The popup is your control panel
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.
### Things to know
- **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 get `Cannot access protected page (chrome://...)` instead of a silent hang.
- **`browser_navigate`** is the one tool where `tabId` is 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 a `complete` event (SPAs don't reload on hash change, so that event never fires).
- **`browser_evaluate`** runs in the page's MAIN world without the debugger banner and returns JSON-serializable values across the MV3 world boundary. It uses page-side `eval`, so a strict page Content Security Policy can reject it. Use `browser_run_action` when CDP-based execution is required. Both tools are powerful and **non-idempotent**, so they are not auto-retried on timeout.
- **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_scroll` returns `refsMayBeStale: true` because 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_dialog` dismisses it via CDP, and `browser_tabs { action: "close" }` always works as the guaranteed way out.
---
## 🧠 Teach Your Agent
The agent can use all 24 tools out of the box, but it works better when it knows the **tab-first** workflow. From the repo root:
```bash
npm run setup:cursor # or: node mcp-server/dist/index.js --setup cursor
```
This installs:
- `~/.cursor/rules/browser-controller.mdc` — the tab-targeting workflow, dropdown handling, when to lock tabs
- `~/.cursor/commands/check-browser.md` — adds `/check-browser` to 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.
<details>
<summary>Claude Code setup</summary>
```bash
npm run setup:claude
```
Adds an `AGENTS.md` to your project root. Claude Code auto-discovers it.
</details>
See [`agent-config/`](agent-config/) for manual installation or to customize the rules.
---
## What It Can Do
24 tools. Every page-interaction tool takes a **`tabId`** (the one exception is `browser_navigate`, where it's optional).
**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. Traverses open shadow DOM + same-origin iframes. |
| `browser_screenshot` | Capture a tab as an image (activates the tab first to capture) |
| `browser_text` | Extract raw text from page or element |
| `browser_find` | Query elements by natural language — walks same-origin iframes too |
**Interact**
| Tool | What it does |
|------|-------------|
| `browser_act` | Safely click/type/select/focus/hover/keypress/scroll/upload against a `browser_observe` snapshot |
| `browser_click` | Click by ref or CSS selector — pierces same-origin iframes |
| `browser_click_text` | Click by visible text. Works through React portals and overlays |
| `browser_type` | Type into inputs and contenteditable fields |
| `browser_press_key` | Key combos (Enter, Escape, Ctrl+A) |
| `browser_scroll` | Scroll pages and virtual containers |
| `browser_hover` | Trigger tooltips and dropdowns |
| `browser_select` | Pick from native `<select>` dropdowns |
| `browser_wait` | Wait for elements to appear or disappear |
| `browser_fill_form` | Fill multiple form fields in one call (React/Vue-safe setters) |
| `browser_drag` | Drag element-to-element (uses CDP for reliability) |
| `browser_upload_file` | Upload files through `<input type="file">` (uses CDP, strict-CSP safe) |
<details>
<summary><b>Uploading files — no file dialog</b></summary>
`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`.
</details>
**Navigate**
| Tool | What it does |
|------|-------------|
| `browser_navigate` | Go to a URL in a tab (`tabId` optional, defaults to active) |
| `browser_tabs` | List / create / close / focus / **lock** / **unlock** tabs |
**Debug & Advanced**
| Tool | What it does |
|------|-------------|
| `browser_console` | Console output (log, warn, error) — per-tab, capped at 200 entries |
| `browser_network` | XHR/fetch requests with status codes — per-tab, optional `limit` |
| `browser_evaluate` | Run JavaScript in the page's MAIN world without a debugger banner; page CSP may block its use of `eval` |
| `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 |
---
## How Others Compare
| | 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 |
| Setup | Build from source + extension | Headless browser | Chrome with `--remote-debugging-port` |
---
## Configuration
| 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 24 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`) |
### Daemon state files
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 |
To fully reset: stop your MCP clients, delete the folder, and the next run recreates it with fresh secrets.
### Reliability
- 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 on `clear: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.
### Protocol compatibility
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.
<details>
<summary>Multiple Chrome profiles</summary>
Run two daemons on different ports by setting `WS_PORT` per client:
```json
{
"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.
</details>
---
## Architecture
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
│ └── tools/ One file per tool (24), 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, tabs, cdp
│ ├── 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/ 19 suites / 291 tests
```
**Stack:** TypeScript (strict) · MCP SDK · WebSocket · Chrome Extension Manifest V3 · Vitest
### Life of a tool call
```mermaid
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](#reliability) for the full list.
### The observe/act runtime
`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](#safe-observe--act-workflow).
## Development
```bash
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` |
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.
### Updating an existing install
```bash
git pull
npm install
npm run build
```
Then 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).
If the popup reports an incompatible protocol, a missing capability, or repeated handshake timeouts, the server and extension are usually from different checkouts/builds:
1. Run `npm install && npm run build` in the repository used by your MCP configuration.
2. 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.
3. Open `chrome://extensions`, find Browser Controller, and click **Reload** so Chrome replaces the long-lived MV3 service worker.
4. Reopen the popup. If authentication rather than compatibility is failing, verify the port and 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.
---
## FAQ
<details>
<summary>Does it work with my logged-in sessions?</summary>
That's the whole point. The extension runs inside your actual Chrome — same cookies, same sessions, same local storage. No re-authentication needed.
</details>
<details>
<summary>Does it send data anywhere?</summary>
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](SECURITY.md) for the threat model, auth design, and the first-contact TOFU window.
</details>
<details>
<summary>Which AI clients work?</summary>
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.
</details>
<details>
<summary>Can two agents really work at the same time without breaking each other?</summary>
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.
</details>
<details>
<summary>What if the agent acts on the wrong tab?</summary>
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`.)
</details>
<details>
<summary>Why are there two secrets (enrollment + token)?</summary>
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 exactly once (out-of-band, before any WebSocket exists); the auth token then authenticates every connection. Both live in `~/.browser-controller/` with mode `0600`.
</details>
<details>
<summary>How is this different from Playwright MCP or browser-use?</summary>
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.
</details>
---
## Contributing
Bug reports, feature requests, and PRs are welcome at [the issue tracker](https://github.com/compnew2006/browser-controller/issues). Open an issue first for larger changes.
## Security
See [SECURITY.md](SECURITY.md) — localhost-only architecture, token + enrollment design, threat model, and reporting guidance.
## License
[MIT](LICENSE)
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessNo issues