agent-browser-bridge
Allows AI agents to control Brave browser profiles, including listing tabs, navigating, clicking, filling forms, taking screenshots, and reading pages in the real logged-in browser.
Provides read-only access to whitelisted Slack Web conversations, allowing agents to read named chats without sending messages or navigating elsewhere.
Provides read-only access to whitelisted Telegram Web conversations, allowing agents to read named chats without sending messages or navigating elsewhere.
Provides read-only access to whitelisted WhatsApp Web conversations, allowing agents to read named chats without sending messages or navigating elsewhere.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@agent-browser-bridgeOpen the client profile and screenshot the current state of the dashboard."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 browserUse 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_tabson 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 ci2. Load the extension in each browser profile
In that profile, open
chrome://extensions(orbrave://extensions).Turn on Developer mode (top right).
Click Load unpacked and pick the
extension/folder of this repo.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.mjsThis 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 |
| one registry value, |
macOS |
| the file's location is the registration; one file per installed browser. The manifest points at a generated launcher, |
Linux |
| same as macOS; launcher at |
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.mjsPlatform | What it registers |
Windows | a Task Scheduler task named "Agent Browser Bridge broker", started hidden through |
macOS | a per-user launchd agent, |
Linux | a systemd user unit, |
--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 pasteEvery 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 matchThe 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.mjsDoctor 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 --allWhy 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
Load the extension into a fresh browser profile (step 2 above).
node scripts/install-host.mjs, thennode scripts/install-broker.mjs.In a new agent session, call
browser_list_profiles. The profile appears within a few seconds. Thenbrowser_list_tabswith that profile's label andbrowser_read_pageon 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 |
| always allowed |
Write |
| allowed, always audited |
Armed |
| refused unless that profile is armed; see What arm does. An upload also needs the upload folder; see Uploading a file |
Control |
|
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_groupmoves 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.movewith 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
notesays 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
activateis 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.
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 asreport.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.The arm rail. The profile has to be armed, the same arm as
browser_eval_js(see What arm does).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.jsonleft 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_permissionsfor all URLs, because the agent may need to read any page you are logged into. It never requestschrome.cookies, and the build gate fails any commit that adds it. The only browser file the broker ever opens isLocal State, and only the profile-name section of it: neverCookies,Login DataorWeb Data.What arm does.
browser_eval_jsruns arbitrary JavaScript inside a logged-in session, andbrowser_upload_filesends 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_filereads only files inside the one folder named inupload.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_armtool, 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. Keepbridge_armoff every auto-approve list: in Claude Code, leavemcp__agent-browser-bridge__bridge_armout ofpermissions.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 filePANICin 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_focusreads 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_focusOPENS or RELOADS afile:URL whose path ends in.htmlor.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 anyfile: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 intofile: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, sofile:/C:/xandfile:\\server\share\xboth canonicalize to ordinaryfile://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-runon 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 guardPure 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.
This server cannot be deployed
Maintenance
Related MCP Connectors
Hosted real Google Chrome MCP with per-user persistent state. Navigate, click, type, screenshot.
Browser MCP for logged-in tasks. Uses your Chrome — credentials stay local. Zero-token replay.
Stealth web browser for agents: search, fetch, click, download and type in persistent MCP sessions.
Browserless MCP — wraps the Browserless headless-Chromium REST API (browserless.io)
Related MCP Servers
- FlicenseNot gradedqualityBmaintenanceEnables 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.-
- AlicenseNot gradedqualityCmaintenanceEnables 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 npmMIT
- AlicenseNot gradedqualityAmaintenanceEnables AI agents to control isolated Chromium browser identities, allowing per-account navigation, interaction, and inspection through MCP without requiring Node or CLI installation.2MIT
- AlicenseNot gradedqualityAmaintenanceEnables 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 npm1MIT