Skip to main content
Glama
RobBrautigam

agent-browser-bridge

by RobBrautigam

Agent Browser Bridge

Drive your real, logged-in browsers from any AI agent. Agent Browser Bridge lets Claude Code, Codex, Cursor or any other MCP client take control of any Chrome or Brave profile on your machine, through the browser you are already using: your cookies, your extensions, your logged-in tabs. Every profile is a separate line, several agent conversations can use the bridge at the same time with no port fights, and nothing in the system listens on the network.

agent session  ->  MCP server  ->  broker  ->  host  ->  extension  ->  your actual browser

Use cases

  • Many AI conversations sharing the browsers on one machine. Every agent session gets its own thin MCP server and they all meet at one always-on broker, so ten windows can work at once and none of them owns a port.

  • One browser profile per company or client. Sign a profile into a company's accounts, give it a label, and an agent works "in this company" by naming that label. Acting on the wrong profile is structurally refused.

  • Tabs tracked across Chrome and Brave from any window. browser_list_tabs on any profile, from any conversation, with opaque handles that can never be used against the wrong browser.

  • Arming a profile to run JavaScript for the clicks DevTools cannot land. CAPTCHA sliders, developer-console buttons that check isTrusted, consoles that only enable Save on real keystrokes: escalate for a bounded window on one profile, then it closes on its own.

  • A panic switch that disarms everything. One press-and-hold in the extension, or one file on disk, drops every route and refuses every call until a human clears it. It does not need the agent's cooperation.

  • A read-only chat bridge for whitelisted conversations. Read a few named chats in WhatsApp Web, Slack or Telegram Web without the agent ever sending or wandering. Recipe and an empty whitelist template are in docs/recipes/read-only-chat-bridge.md.

  • Verification walks of a logged-in web app. Have the agent click through the real product as a real user, screenshot every state, and report what actually rendered, in the same session it just deployed from.

Related MCP server: chrome-bridge

How it works

Four pieces, one job each.

  • The extension runs in each browser profile, loaded unpacked from a single shared folder. Its service worker opens exactly one native-messaging port, which is also what keeps the worker alive: Chromium grants native messaging an unconditional keepalive.

  • The host is a byte relay. The browser spawns one per profile and owns its lifetime. It knows nothing except how to forward frames.

  • The broker is the only stateful component and the only always-on one. It holds the route table, works out which profile each connection belongs to, enforces policy, and writes the audit log. A supervisor keeps it alive: Task Scheduler on Windows, launchd on macOS, systemd on Linux.

  • The MCP server is spawned fresh by every agent session. It translates tool calls into broker requests and shapes the results. It never touches a browser, which is why closing a session cannot disturb your browsers.

There is no TCP listener anywhere. The broker's endpoint is a Windows named pipe or a Unix domain socket, guarded by a token that rotates every time the broker starts. A web page cannot reach the bridge because there is nothing listening for it to reach.

The full reasoning, including the architecture that lost and why, is in docs/DESIGN.md.

Install

You need Node 22 and Chrome or Brave. About five minutes, once per machine, then thirty seconds per browser profile.

1. Clone and install

git clone https://github.com/RobBrautigam/agent-browser-bridge.git
cd agent-browser-bridge
npm ci

2. Load the extension in each browser profile

  1. In that profile, open chrome://extensions (or brave://extensions).

  2. Turn on Developer mode (top right).

  3. Click Load unpacked and pick the extension/ folder of this repo.

  4. Note the ID the page shows under the extension's name. You will compare it in the next step.

Do this in every profile you want the agent to reach. Every profile loads the same folder.

Treat the clone as a standalone install folder: never move, rename or remove it once a profile has loaded it. The browser derives an unpacked extension's ID from the folder path (step 3 below), so a moved folder is a different extension as far as every profile is concerned: the native host stops matching, every profile drops off the bridge, and you would have to load it again everywhere. Do your development in a second clone or a git worktree, and keep this one where it is. node scripts/keygen.mjs pins the ID with a key only when it runs before any profile has loaded the extension; on an install that is already in use it changes the ID on the spot, which is the same reload-everywhere cost as moving. Decide on a fresh clone; on a live install, do not move it.

3. Register the native messaging host

node scripts/install-host.mjs

This derives the extension ID from the folder path (exactly as the browser does for an unpacked extension), writes the native messaging manifest with that ID in allowed_origins, and points each browser at it. It prints the ID it used. Compare it with the ID chrome://extensions shows. If they differ, run it again with the browser's ID:

node scripts/install-host.mjs --extension-id <the id chrome://extensions shows>

Where it writes, per platform:

Platform

Manifest

Pointer

Windows

%LOCALAPPDATA%\agent-browser-bridge\com.agent_browser_bridge.host.json

one registry value, HKCU\SOFTWARE\Google\Chrome\NativeMessagingHosts\com.agent_browser_bridge.host. Brave reads Chrome's key, so one value serves both.

macOS

~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.agent_browser_bridge.host.json and ~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/com.agent_browser_bridge.host.json

the file's location is the registration; one file per installed browser. The manifest points at a generated launcher, ~/Library/Application Support/agent-browser-bridge/host-launcher.sh, with the absolute path to node baked in.

Linux

~/.config/google-chrome/NativeMessagingHosts/com.agent_browser_bridge.host.json and ~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/com.agent_browser_bridge.host.json

same as macOS; launcher at ~/.local/state/agent-browser-bridge/host-launcher.sh.

Optional: node scripts/keygen.mjs pins the extension ID with a key in the manifest, so the ID survives moving the folder. Run it before step 2 if you want that; the installer then derives the ID from the key instead.

4. Start the always-on broker

node scripts/install-broker.mjs

Platform

What it registers

Windows

a Task Scheduler task named "Agent Browser Bridge broker", started hidden through wscript.exe at logon, with a one-minute watchdog. No admin rights.

macOS

a per-user launchd agent, ~/Library/LaunchAgents/com.agent_browser_bridge.host.broker.plist, with KeepAlive.

Linux

a systemd user unit, ~/.config/systemd/user/agent-browser-bridge-broker.service, with Restart=always.

--dry-run prints exactly what would be written. --uninstall removes it.

5. Register the MCP server with your agent

node scripts/install-mcp.mjs                   # Claude Code, edits ~/.claude.json
node scripts/install-mcp.mjs --client cursor   # Cursor, edits ~/.cursor/mcp.json
node scripts/install-mcp.mjs --client codex    # Codex, prints the TOML to paste

Every installer backs up the file it edits and changes exactly one key. If you would rather add it by hand, this is the entry. Replace the path with your clone's absolute path, forward slashes on every platform.

Claude Code (~/.claude.json, inside mcpServers):

"agent-browser-bridge": {
  "type": "stdio",
  "command": "node",
  "args": ["/path/to/agent-browser-bridge/mcp-server/index.mjs"],
  "env": {}
}

Cursor (~/.cursor/mcp.json, inside mcpServers):

"agent-browser-bridge": {
  "type": "stdio",
  "command": "node",
  "args": ["/path/to/agent-browser-bridge/mcp-server/index.mjs"],
  "env": {}
}

Codex (~/.codex/config.toml):

[mcp_servers.agent-browser-bridge]
command = "node"
args = ["/path/to/agent-browser-bridge/mcp-server/index.mjs"]

Start a new agent session afterwards; MCP config is read at startup.

6. Claim Brave profiles, once

Chrome profiles resolve themselves from the signed-in account. Brave writes no account identity into its profile metadata, so a Brave profile needs one click: open the extension's options page in that profile (the toolbar icon, then "Open the board") and pick which profile you are in. Once, ever.

The same claim without the click, for many profiles or a terminal-driven install:

node scripts/claim.mjs                                    # every line, with the choices of the unclaimed ones
node scripts/claim.mjs brave-unclaimed-ab12 "work@example.com"   # claim that line as the one exact match

The name must match a profile's name or email exactly and uniquely, or the script refuses and sends nothing. A line that is already claimed is only moved with --reclaim.

7. Check it

node scripts/doctor.mjs

Doctor checks the host registration, the extension ID, the broker service, whether the broker answers, the MCP registration, and, most usefully, whether every profile you have configured is actually connected right now. Unpacked extensions can be disabled silently, and that check is the only way you find out.

Updating

Pull, install the dependencies, then reload the extension in each profile. The last step is the part that is easy to miss and it matters:

cd agent-browser-bridge
git pull
npm ci
node scripts/reload-extension.mjs --all

Why the reload is not optional. A browser reads an unpacked extension's code once, when it loads it. Pulling new code into this folder changes nothing in a running browser: every profile keeps serving the version it loaded, which is why a fixed bug can still be there after an update. The broker is a separate process and does pick the new code up, at its next restart, so an install can sit with three different versions running at once.

node scripts/reload-extension.mjs --all is the fix, and it does the whole job: it reloads every connected profile that is behind, skips the ones that are not and says why, and waits for each profile to come back on the new version before it reports success. --dry-run shows what it would do. node scripts/doctor.mjs and browser_list_profiles both name any profile that is behind, so you can check without guessing.

The one time you have to click. A release older than 0.4.0 cannot reload itself, because the code that would do it is the code being replaced. So upgrading FROM 0.3.0 or earlier needs the Reload arrow on this extension's card on chrome://extensions, once per profile. Every release after that is the command above. Restarting the browser also works, and so does restarting the machine.

The broker picks up new code when it restarts, which the supervisor does at login. To restart it now: schtasks /end /tn "Agent Browser Bridge broker" on Windows and let the watchdog start it again a minute later, launchctl kickstart -k gui/$UID/com.agent_browser_bridge.host.broker on macOS, systemctl --user restart agent-browser-bridge-broker on Linux. node scripts/doctor.mjs warns when the running broker is on a different version from its install folder, and prints that step.

A clone from before 1.0.0. The repository restarted from a new first commit at 1.0.0, so git pull in an older clone refuses with "refusing to merge unrelated histories". Once, in the same folder, run git fetch origin and then git reset --hard origin/main, and carry on from npm ci above. Never clone into a new folder instead: a new folder is a new extension ID, with the same cost as moving it (step 2 of Install). The reset discards local changes and local commits in the folder, so copy out anything of your own first. Two things to put back right after it: if you pinned the ID with node scripts/keygen.mjs, run it again, and it writes the same key back from its saved copy; if you customized bridge.config.json, restore it and run npm run sync-config. The 1.0.0 entry in CHANGELOG.md has the full upgrade.

The two-minute smoke test

  1. Load the extension into a fresh browser profile (step 2 above).

  2. node scripts/install-host.mjs, then node scripts/install-broker.mjs.

  3. In a new agent session, call browser_list_profiles. The profile appears within a few seconds. Then browser_list_tabs with that profile's label and browser_read_page on any handle it returned.

If the profile does not appear, node scripts/doctor.mjs names the step that failed and the command that fixes it.

The tools

Every tool that touches a page takes profile, and it is required even when only one profile is connected. Acting on the wrong browser is the worst thing this system could do, and a default is exactly how that would happen.

Tab handles look like tab_chrome-work_3_41. They carry the profile and the browser-session generation, so a handle from one profile is rejected by another, and a handle from before a browser restart fails loudly instead of landing on whatever tab now holds that number.

Tier

Tools

Policy

Read

browser_list_profiles, browser_list_tabs, browser_list_groups, browser_read_page, browser_screenshot, browser_scroll, bridge_status

always allowed

Write

browser_navigate, browser_open_tab, browser_open_or_focus, browser_close_tab, browser_activate_tab, browser_click, browser_fill, browser_press_keys, browser_wait_for, browser_sort_window, browser_group_tabs, browser_update_group, browser_move_group, browser_ungroup_tabs, browser_gather_group, browser_reload_extension

allowed, always audited

Armed

browser_eval_js, browser_upload_file

refused unless that profile is armed; see What arm does. An upload also needs the upload folder; see Uploading a file

Control

bridge_arm, bridge_panic

browser_read_page with format snapshot returns a tree of interactable elements with stable refs, which browser_click and browser_fill prefer over CSS selectors. browser_fill has a set mode that defeats React's value tracker and a type mode that sends real keystrokes for consoles that only enable Save on them. A password or one-time-code field is refused unless the session carries a recorded yes for that site (see Security).

browser_sort_window puts a window's oldest tab on the left and its newest on the right, for one window or every window of a profile, each on its own. Pinned tabs stay put and a tab group moves as one block, placed by its oldest tab. The popup has the same two buttons. A tab's age is the open time the extension recorded when the tab was created; tabs opened before 1.1.0 have none, so they are aged by when they were last shown.

Tab groups

browser_list_groups lists each window's groups (id, title, color, collapsed, count, where it starts) with their tabs as handles. browser_group_tabs puts tabs into the group with that exact title in their window, in the order given, making the group there if the window has none. browser_update_group renames, recolors, collapses or expands one; browser_move_group moves one within its own window (index: -1 is the end); browser_ungroup_tabs takes tabs out. browser_gather_group is the opt-in exception to staying in one window: given a title and a window, it brings every group of that title from every window into that one and folds them into one group.

Four rules come from the browser's own API and are enforced, not documented away:

  • No tab changes window on its own. A group lives in one window, so tabs from two windows are refused, a new group is always made in its tabs' own window (the API's default is the current one, which would drag them there), and only browser_gather_group moves anything to another window: the tabs of groups of one title, one by one, into the window it was named. A moved tab arrives unselected, so that window keeps showing what it showed. The window a group's active tab left shows whatever tab the browser picks next, and a window left with no tabs is closed by the browser; no tab is closed. The whole-group window move (chrome.tabGroups.move with a window) is never used: its first call on a real browser closed that browser.

  • Pinned tabs are refused, because grouping a tab unpins it.

  • A group holding its window's active tab is not collapsed. The browser would switch that window to another tab; the result's note says it was skipped.

  • Nothing is closed, reloaded, navigated, opened or activated, with one exception in the gather: a window whose active tab is gathered away shows the tab the browser picks next (a discarded one reloads), and a window the gather leaves with no tabs is closed by the browser. No tab is closed.

For a whole layout, node scripts/group-tabs.mjs <profile label> <plan.json> applies a plan:

{ "groups": [
  { "title": "Decide", "color": "red", "tabs": ["tab_chrome-work_3_41", "tab_chrome-work_3_17"] },
  { "title": "Read later", "color": "grey", "collapsed": true, "tabs": ["tab_chrome-work_3_9"] }
] }

In each window as it stands, every group is filled with the plan's tabs that are in that window, in order, and moved to the end of the window, so the groups stand left to right in plan order; the collapsed ones are collapsed last; then everything is listed again and the script exits 1 if any tab is not in its group. --dry-run lists and plans without changing anything. The script can send only the four list, group, update and move operations, and refuses anything else before it reaches the broker.

To gather a layout into one window instead, add "window": a window id, or "last-focused" for the window used last (browser_list_groups marks it).

{ "window": "last-focused", "groups": [
  { "title": "Decide", "color": "red", "tabs": [] },
  { "title": "Read later", "color": "grey", "collapsed": true, "tabs": [] }
] }

Every group of each planned title, in every window, is gathered into that window and folded into one, then the groups are lined up left to right in plan order, colored and collapsed as planned. A group holding its window's active tab is gathered too, and a window the gather empties is closed by the browser. Tabs named in tabs join their group when they are in that window; one in another window and in no group of that title is left there and reported. The read-back fails while a planned title still stands in more than one group or outside that window. With "window" set, and only then, the script's client also passes the gather, and only into that window.

Showing a page to the human at the machine

browser_open_or_focus is the tool for a page a PERSON is meant to read, and the one to reach for when an agent regenerates a report and shows it again. Given a profile and an address it finds the tab already showing that page, reloads it and slides it to the far right of the window it is already in; with no such tab it opens one at the far right of that profile's most recently focused window. The result is one line saying what it did: reused, moved from which index to which, reloaded, or opened new.

Three properties, and one thing to know:

  • It does not take the keyboard. Moving and reloading a tab changes nothing about where input goes. Raising a window does, so activate is off by default and the caller has to ask.

  • It never closes a tab it did not open. Duplicates are closed only when its own ledger says this tool opened them AND the tab still shows that page when the moment to close it arrives; a copy the human opened is left alone and counted in the answer.

  • It cannot land in the wrong profile. The operation runs inside the extension instance of the profile it names, and that instance can only see its own windows, so "the most recently focused window" is that profile's even when a different profile's window is the one on screen.

  • It does reload the tab it reuses, which is the point, and a reload discards anything unsaved in that tab. Point it at pages you are showing someone, not at a form somebody is halfway through filling in.

Local files, and the exact line it draws. It OPENS and RELOADS a file: address only when the path ends in .html or .htm. For any other local file, a PDF a report was exported to, an image, a CSV, it will FIND the tab already showing that address and move it to the far right, and it will refuse when no such tab exists, saying which rule refused it. So a launcher opens the PDF itself the first time and calls this afterwards, and the person still ends up with one tab per document. Finding and moving navigates nothing and reads nothing, which is why it is not a widening of the file rule; the reasoning is in SECURITY.md.

The same capability without an MCP client, for a launcher, a hook or a shell script:

node scripts/open-or-focus.mjs <profile label> <url or file path>

It prints the same one line and exits non-zero if the page did not land, so a caller can fall back to its own opener and say so.

Reading a page in a signed-in profile, from a script

For a research script that needs the text of a page only a signed-in browser can see (a post behind a login, a thread whose replies load as you scroll), there is a read command that touches nothing on the page:

node scripts/read-page.mjs <profile label> <url> [--comments] [--json]

It opens the address in a BACKGROUND tab of that profile, waits for it to load and for its text to stop changing, prints the readable text, and closes the tab. --comments scrolls the page down first, about one viewport per step and at most ten steps, reading after each one, so a thread that loads as it is scrolled is in the read, and a feed that unmounts what scrolled away keeps its first post. --json prints one object with the final URL, the title, the character count, the text and whether the tab was closed. The whole read has a two-minute deadline, and Ctrl+C ends it at the next step with the tab closed.

Exit codes: 0 the page landed and its tab closed; 1 it did not land (the reason is on stderr, so a caller can fall back to another reader); 2 a usage error; 3 the text was read but the tab could not be closed (a bridge reconnect, the panic switch), so a caller can use the text and stop reading. Text mode strips control characters, so a page cannot send escape sequences to the terminal; --json escapes them.

Read-only by construction. Every request it makes goes through a wrapper that passes five operations, open a tab, list tabs, read a page's text, scroll and close a tab, and refuses everything else before it reaches the broker. It has no click, fill, key press, navigation or caller JavaScript, so there is no control on the page it can press, a like, a follow or a reply included. The wrapper also opens tabs in the background only, reads, scrolls and closes only the tab its own open returned (never a missing tab, a foreign handle or a raw tab id), and scrolls by direction only, never to an element. The tests check what it sends; a source scan catches the obvious slip, and the wrapper is the guarantee.

What it does not do, on purpose. Replies behind a "show more replies" or "load more comments" button stay unloaded, because loading them takes a click. Content that loads only when the page is visible may not load in a background tab. In live checks, a forum thread showed its first comments without any scrolling and the same 58 replies after five background scroll steps, and a video page's comments did not load at all. So --comments helps where a page loads more on a scroll event the background tab still receives; it is not a promise of the whole thread.

The visit itself is real. It presses nothing, but opening a page in a signed-in profile is a signed-in visit: a link that does something when it is opened (sign out, unsubscribe, confirm, download) does it, and a site records that the profile viewed or scrolled the page (profile views, read receipts, story views). Do not point it at such links. The tab appears in the profile's last-focused window while it is read.

It reads through the browser's own session: it never reads, copies or exports a cookie, token or password, though the printed text is whatever the page shows. How often it is pointed at one site is the caller's job; a script that reads many pages should space them out and stop at the first login wall or challenge page.

Which version each profile is running

browser_list_profiles reports, for every profile, the extension version it is RUNNING, and flags any profile whose version is behind the one in the install folder. That is three numbers that drift apart on purpose: the broker's version is fixed when it starts, the folder's changes the moment you pull, and a profile's changes only when that extension is reloaded. bridge_status counts how many profiles are behind, and the extension's own board shows it per line.

browser_reload_extension fixes it without anybody clicking: it asks that profile's extension to reload itself, which is exactly what the Reload arrow on the extensions page does. It is refused unless the folder holds a different version from the one that profile is running, because a reload is not free: it invalidates every open tab handle in every agent session driving that browser, and it clears the ledger browser_open_or_focus uses to know which tabs it opened. Once per release that is a fair trade; on demand it would be a way to disrupt other sessions.

The profile drops off the bridge for a second or two and comes back on the new code. The result says the reload was ASKED for, which is all an answer can honestly claim: reloading tears down the port the answer travels on, so the acknowledgement leaves before the reload happens. browser_list_profiles is what confirms it landed. From a shell, node scripts/reload-extension.mjs --all does the asking and the confirming in one command.

Uploading a file

browser_upload_file puts files into a page's file input, as if you had picked them. A file going from your disk to a website is the move a poisoned page would most like an agent to make, so the tool does nothing until you name a folder, and it runs behind three rails. A refusal names the rail that refused.

  1. The folder rail. Only files inside the ONE folder you name are ever read. With no folder named, every upload is refused, which is the default. Each path is resolved to its real location before anything opens it, so a .., a symlink or a junction that leads out of the folder is refused, and so is anything that is not a regular file or names a hidden part of one (an NTFS stream such as report.pdf:hidden). The broker reads the bytes itself and the extension gets them under the file's bare name: no path reaches the browser, and the browser never reads your disk.

  2. The arm rail. The profile has to be armed, the same arm as browser_eval_js (see What arm does).

  3. The audit rail. Every upload is written to the audit log before it leaves, with the tab and each file's name and size, and again once the page has it, with the site's origin. When the log cannot be written, the upload does not go out.

Name the folder in upload.json in the state directory (on Windows, %LOCALAPPDATA%\agent-browser-bridge\upload.json). The broker reads it on every upload, so no restart is needed:

{ "folder": "C:\\Users\\you\\BridgeUploads" }

Paths are given by name inside that folder (invoice.pdf, scans/page-1.png) or as an absolute path inside it. At most 10 files and 20 MB together. The tool fills the input and fires the same input and change events a person's pick does; it does not submit the form. An upload box inside an iframe is not reachable, because page operations work in the main frame only.

Security

Stated plainly, because a security model nobody believes is worse than none.

  • Local only. Nothing listens on the network. The broker's endpoint is a named pipe (Windows) or a Unix socket file (macOS, Linux), and every client proves a 256-bit token the broker mints on each start and writes to a file only your user can read. The broker proves it back, so the token never crosses the pipe and a process squatting on the pipe name gets nothing, not even behind a runtime.json left over from an older broker (a client that finds no scheme in it does not dial). A leaked token dies at the next restart.

  • What the extension can read. It has host_permissions for all URLs, because the agent may need to read any page you are logged into. It never requests chrome.cookies, and the build gate fails any commit that adds it. The only browser file the broker ever opens is Local State, and only the profile-name section of it: never Cookies, Login Data or Web Data.

  • What arm does. browser_eval_js runs arbitrary JavaScript inside a logged-in session, and browser_upload_file sends a file from your disk to a website, so both are refused unless that profile is armed, for that profile only, for a bounded window (60 minutes at most, 15 by default). Reading, clicking, navigating and typing never need arming.

  • Uploads read one folder. browser_upload_file reads only files inside the one folder named in upload.json, on their real paths, and nothing at all when none is named. The broker reads them, so no path reaches the browser. Every upload is audited before it leaves (the tab, each file's name and size) and once it lands (the origin), and does not go out when its line cannot be written. See SECURITY.md for what this does not defend.

  • Who can arm. You can, from the Board. So can the agent, through the bridge_arm tool, whose description tells it to ask you first; the broker cannot tell which of you asked. The human step in front of an agent's arm is therefore your agent client's tool-approval prompt. Keep bridge_arm off every auto-approve list: in Claude Code, leave mcp__agent-browser-bridge__bridge_arm out of permissions.allow, so each arm asks you. A web page cannot arm: the extension's worker answers only its own pages.

  • What panic does. bridge_panic, the press-and-hold control in the extension, or simply creating the file PANIC in the state directory drops every route, disarms everything and refuses every call until a human clears it: "Hold to resume" in the extension popup, or deleting that file. No agent can clear it, on purpose: there is no tool for it, and the broker drops an agent connection that asks.

  • Passwords need the recorded yes. A fill or key press into a password or one-time-code field is refused unless the agent session was launched with a receipt pointing at a recorded yes for that site, read by the broker from one configured folder. The audit line names the receipt's file, never its contents. See SECURITY.md for what this does and does not cover.

  • Identity is checked, not assumed. A profile is claimed to a person, and the broker re-reads who is signed in about once a minute. If the account changes, the claim is dropped and the old label stops resolving, so a tool call fails loudly instead of acting as the wrong person. Chrome carries the full check; Brave records no account, so there it falls back to the profile name.

  • The audit log records origin only. Scheme, host and port of every write operation, never the path, query or fragment, because password-reset and magic-link tokens live in paths. browser_open_or_focus reads tab addresses to find its match, and it is audited under the same rule: what lands in the log is the origin, which for a local page is the scheme alone.

  • One local-file exception, as narrow as its job. file: URLs are refused everywhere, because navigating to one and reading it back would be a local-file read primitive. browser_open_or_focus OPENS or RELOADS a file: URL whose path ends in .html or .htm, and nothing else, because showing a generated page to a human is the job it exists for. Every other local file keeps that refusal, so there is no arbitrary file to aim a tab at. The same tool may find a tab already showing any file: address and move it, which navigates nothing and reads nothing and so cannot be half of the navigate-then-read composition. Chromium also refuses to inject into file: pages unless you turn on this extension's "Allow access to file URLs" toggle, which nothing here requests or sets, so reading such a tab back fails on a default install.

  • A refused scheme is refused however it is spelled. The refusals are checked as schemes, not as text prefixes, because the number of slashes in a URL is not load-bearing: file: is a special scheme, so file:/C:/x and file:\\server\share\x both canonicalize to ordinary file:// URLs. Until 0.4.0 the check was a prefix match and both forms slipped past it, which was a real hole and is fixed with a test named after it.

  • No telemetry. Nothing phones home. There is no analytics, no update check, no crash reporter. The only outbound connections are the ones the agent asks the browser to make.

  • What it does not defend. Code already running as your user can read your cookie database off disk without this bridge. Prompt injection is bounded by the tiering, the arm, the panic switch and the audit log, not eliminated. See SECURITY.md.

Platforms

  • Windows is the reference platform. The whole chain, including the end-to-end harness against a real browser, has been run there.

  • macOS and Linux follow Chromium's documented native messaging locations and the platform's standard supervisor. The code paths are written and unit-tested, but have not yet been exercised on a real Mac or Linux machine. Reports and fixes are welcome; --dry-run on every installer shows exactly what would be written before anything is.

Renaming it

The product's name lives in one file, bridge.config.json. To ship this under your own name:

npm run rename -- my-bridge "My Bridge"

That rewrites the config, the extension's copy of it, the manifest and package.json. The gate fails if any of them drift from the config afterwards.

Development

npm test                 # unit tests, node --test, no framework
npm run gate             # the security rules, mechanically enforced
npm run doctor           # diagnose an install
npm run e2e              # the whole chain, against a real Brave and a throwaway profile
npm run reload           # reload the extension in every profile that is behind
npm run hooks:install    # the gate before every commit, and the commit-message guard

Pure ESM, Node 22, no build step, no bundler. Every file runs as written. Two runtime dependencies (zod and @modelcontextprotocol/server), zero native modules, and the gate keeps it that way.

See CONTRIBUTING.md.

License

MIT. See LICENSE.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables MCP clients to drive individually named Chrome profiles, providing tools for tabs, navigation, page interaction, screenshots, JavaScript evaluation, console logs, and network inspection over stdio without a TCP port.
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables MCP clients to directly operate your existing logged-in Chrome profiles, including cookies and extensions, without re-authentication or a headless browser. It provides tools for managing tabs, navigating, reading pages, clicking, typing, and launching profiles.
    3 npm
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI agents to control isolated Chromium browser identities, allowing per-account navigation, interaction, and inspection through MCP without requiring Node or CLI installation.
    2
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI agents to see and control the user's real Chrome/Brave/Edge profile over MCP, so they can read pages, click, type, take screenshots, audit layouts, debug CSS, and scrape paginated or infinite-scroll data. Because it drives the normal browser via the DevTools protocol with real input events, it works on modern JavaScript apps and on sites where the user is logged in.
    14 npm
    1
    MIT