Browser Link
БесплатноНе проверенMCP server that bridges Claude Code, OpenCode, GitHub Copilot CLI and other MCP clients to a Chrome tab. Per-tool permissions, multi-agent mode (multiple MCP cl
Описание
MCP server that bridges Claude Code, OpenCode, GitHub Copilot CLI and other MCP clients to a Chrome tab. Per-tool permissions, multi-agent mode (multiple MCP clients sharing one bridge), persistent UI map across sessions.
README
🔗 browser-link
Developer-focused bridge between your MCP client (Claude Code, OpenCode, GitHub Copilot CLI, …) and the Chrome tabs you explicitly enable.
Built for developers debugging real UIs from the agent's seat — reproducing bugs, validating fixes, teaching the agent how an app actually behaves. Not a consumer browser-automation product.
⚠️ Read this before installing
browser-linkopens a bridge between your MCP client and the Chrome tabs you explicitly enable through a companion extension. On every tab where you press "Connect this tab" in the extension popup, the agent can read its DOM, click, type, press keys (trusted keyboard input), drag, run a multi-stepbrowser.flowsequence, run arbitrary JavaScript, navigate, answer native dialogs (alert/confirm/prompt), follow popups opened by the page (window.open/target=_blank), and pre-grant or pre-deny browser permissions (geolocation, notifications, camera, microphone, clipboard, sensors) for the tab's origin — including any logged-in session, saved card, wallet, banking page or admin panel that tab is currently showing.This is a developer tool, not a consumer-grade browser-automation product. Treat the agent like a junior dev with remote control of those tabs. Only enable tabs where you would let an automated process act on your behalf, and disconnect them when you are done. You are responsible for every action the agent performs on the tabs you explicitly enable.
Contents
- What it is
- Quick start
- How it works
- Use cases
- What the agent can do
- Persistent UI map
- Customising
- cdp-direct mode (no extension)
- Security model
- Where your data lives
- For contributors
- Author
- License
What it is
A development-oriented MCP server that lets your editor's agent see and act on a Chrome tab you explicitly enable — without giving it your whole browser. Designed for the loop "user reports bug → agent reproduces it in the real UI → agent fixes the code → agent re-verifies in the same tab", not for unattended consumer automation.
- ✅ What it does — exposes 31
browser.*MCP tools: 25 to drive a connected tab (list_tabs, ping, snapshot, find, state, canvas_screenshot, click, type, press, drag, flow, navigate, evaluate, wait_for, wait_for_tab, dialog_respond, set_permission, console, network, network_body, claim/release/my_tabs, events, reset) plus 6 persistent-map tools, so the agent learns your apps across sessions. - ✅ What it needs — Node ≥ 22.13 and Chrome / Chromium / Edge / Brave
/ Vivaldi. No accounts, no telemetry, no outbound calls except
npmwhen you runCheck for updates. - 🚫 What it does NOT do — touch tabs you have not pressed Connect on, send anything off your machine, persist domain data in the map (selectors and flows only, structure never content).
- 🔒 How it stays private — WebSocket bridge is loopback-only
(
127.0.0.1:17529) and kernel-attested per connection.
Quick start
npm install -g @jobshimo/browser-link
browser-link
The second command opens a full-screen interactive UI (English / Spanish) built on Ink. It walks you through the four-step setup:
- Register
browser-linkwith your MCP client. Pick Claude Code (writes~/.claude.json/%USERPROFILE%\.claude.json), OpenCode (writes~/.config/opencode/opencode.jsonon every OS), or GitHub Copilot CLI (writes~/.copilot/mcp-config.json, override viaCOPILOT_HOME). Restart the client afterwards. - Drop the trigger block into the agent's global
.md. Optional but recommended — see Agent instructions below. Without it the agent has no reason to callbrowser.snapshotwhen you say "the button is broken"; it will read code and guess. - Install the Chrome extension. The UI prints the absolute path to the
bundled assets and the OS-specific steps (
chrome://extensions→ Developer mode → Load unpacked). - Connect a tab. Click the browser-link icon in your Chrome toolbar and press Connect this tab on the tab you want the agent to see. The popup and its copy are English-only.
╭─ browser-link — setup ────────────────────────────────────────────╮
│ v0.19.1 │
│ │
│ Pick an action ⋅ or press the bracketed key│
│ │
│ SETUP │
│ ❯ [r] Register browser-link with an MCP client │
│ [i] Agent instructions — trigger block in global .md │
│ [p] Permissions — which browser.* tools to expose │
│ [m] Multi-agent — let multiple MCP clients share one bridge │
│ │
│ DIAGNOSE │
│ [d] Run doctor (diagnose current setup) │
│ [u] Check for updates on npm │
│ [f] Free port — stop a stuck browser-link holding 17529 │
│ │
│ REFERENCE │
│ [e] Show Chrome extension install steps │
│ [L] Language — switch between English and Español │
│ [w] Show the welcome screen │
│ [a] About / Help — what is this and how it works │
│ [g] Open the GitHub repository │
│ [q] Quit │
│ │
│ ↑↓ navigate · ↵ select · a-z hotkey · l language · q quit │
╰─────────────────────────────────────────────────────────────────────╯
Every action above is also a subcommand you can script:
browser-link install # register in every detected client
browser-link install --client claude # register only in Claude Code
browser-link uninstall --client opencode # remove from one client
browser-link instructions # status of the trigger block per client
browser-link instructions install # insert/refresh the block in every detected client
browser-link instructions uninstall --client claude
browser-link extension # show extension assets path + steps
browser-link doctor # diagnose current setup
browser-link tools # show which MCP tools are enabled
browser-link tools disable browser.evaluate
browser-link tools preset readonly # all | readonly | no-eval | no-map
browser-link config get # list every known setting
browser-link config set idle-ttl 15 # idle-disconnect TTL, in minutes ("never" disables it)
browser-link config set flow-recording on # opt in to recording flows by demonstration (off by default)
browser-link config set cdp-direct.enabled true # opt in to cdp-direct mode (off by default, see below)
browser-link cdp allow # grant time-boxed cdp-direct access (only you can run this)
browser-link cdp status # enabled?, port, grant remaining, endpoint reachable?
browser-link cdp revoke # revoke the current cdp-direct grant
browser-link map # list apps the persistent UI map knows about
browser-link map show <app> # entries + flows for one app (app_key or origin)
browser-link map forget <app> --yes # delete a whole app and its data
browser-link map forget <app> --flow <name> # delete just one named flow
browser-link map export --out map.json # export the map (or one app) as JSON
browser-link map import map.json # import an export (merge by default, --replace to overwrite)
browser-link multi-agent disable # opt out of the default shared-bridge mode
browser-link multi-agent auto-reelect enable
browser-link stop # kill a browser-link holding port 17529 (zombie)
browser-link updates # check the npm registry for a newer version
browser-link about # the full help page
browser-link help # list every subcommand
How it works
┌──────────────────────────────────────────────────────────────────┐
│ Your MCP client (Claude Code, OpenCode, Copilot CLI, …) │
└──────────────────────┬───────────────────────────────────────────┘
│ stdio (MCP)
▼
┌──────────────────────────────────────────────────────────────────┐
│ browser-link MCP server (Node ≥ 22.13) │
│ ─ listens on 127.0.0.1:17529 (loopback only) │
│ ─ exposes browser.* tools + browser.map.* persistent UI map │
└──────────────────────┬───────────────────────────────────────────┘
│ WebSocket (loopback)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Chrome extension (Manifest V3, custom, ships with the package) │
│ ─ inert by default │
│ ─ activates per-tab when the user clicks "Connect this tab" │
│ ─ uses chrome.debugger (Chrome DevTools Protocol) underneath │
└──────────────────────┬───────────────────────────────────────────┘
│
▼
Browser tab
(only the connected ones)
- The WebSocket bridge only binds to
127.0.0.1— never on a public interface, never reachable from anywhere outside your machine. - Tabs you do not connect remain invisible to the agent. You connect them one by one, by hand.
- Disconnecting a tab from the extension popup immediately revokes the bridge for that tab. The bridge itself survives MCP client restarts — if no tool call lands for the configured idle TTL (30 minutes by default, user-configurable, see Idle-disconnect TTL), the extension parks the tab on its own and you re-press Connect when you want it back.
FAQ: the yellow "started debugging this browser" bar
What it is. Driving a tab through chrome.debugger (see the diagram
above) makes Chrome show a yellow infobar on that tab for as long as the
debugger session is attached. It is Chrome's own UI, not something
browser-link renders — no extension can hide or dismiss it through its own
APIs.
Why it exists. This is intentional transparency on Chrome's part, and we treat it the same way — see the CDP-interaction decision in DECISIONS.md: the bar is the one unmistakable signal that a tab is currently intervened, so you always know which tab an agent can see and act on. browser-link does not try to work around it.
Silencing it — an informed opt-out, not a fix. Chrome supports a launch
flag, --silent-debugger-extension-api, that suppresses the infobar. This
is a Chrome switch you apply yourself when starting the browser; browser-link
does not set it for you:
- Windows — right-click the shortcut you use to launch Chrome →
Properties → append
--silent-debugger-extension-apito the end of the Target field, after the closing quote of the.exepath:"C:\Program Files\Google\Chrome\Application\chrome.exe" --silent-debugger-extension-api - macOS — launch from a terminal:
open -a "Google Chrome" --args --silent-debugger-extension-api - Linux — launch from a terminal:
google-chrome --silent-debugger-extension-api
The tradeoff. The flag is global to that Chrome instance: it silences
the infobar for every extension using chrome.debugger, not just
browser-link — including any other debugger-API extension you happen to
have installed. Only reach for it once you understand and accept that
tradeoff. browser-link doctor reports, best-effort, whether a running
Chrome/Chromium process was launched with the flag, so you can confirm the
state of your own setup instead of guessing.
Use cases
- Reproduce a reported bug on a tab and verify it exists.
- Validate that a fix actually solved a bug, end-to-end, in the real UI.
- Give the agent real context (DOM, console, network) about what is happening in a view it is investigating.
- Build incremental UI knowledge: the agent learns selectors, flows and gotchas for each app and remembers them across sessions.
What the agent can do
The MCP server registers two families of tools — 25 bridge tools that drive the connected tab (13 read, 12 action) and 6 persistent-map tools (2 read, 4 write). All 31 are individually toggle-able — see Per-tool permissions.
Browser bridge — read-only (no claim required, observation only):
| Tool | Purpose |
|---|---|
browser.list_tabs |
List tabs currently connected through the extension, with claim status (claimed_by / claimed_by_me) |
browser.my_tabs |
List the tabs the calling agent currently holds a claim on |
browser.ping |
Verify the bridge to a tab; returns its title and URL |
browser.snapshot |
Title, URL, visible text and interactive elements with selectors; filterable, pierces open Shadow DOM + iframes |
browser.find |
Locate one element by visible text → selector + coordinates; near_misses on no match, candidates on ambiguity |
browser.state |
Compact orientation read — URL/title/viewport/focused element/open dialogs/scroll — cheaper than a full snapshot |
browser.canvas_screenshot |
Screenshot a <canvas> as PNG/JPEG, for DOMless UIs (Qt-WASM, WebGL) where snapshot/find return nothing |
browser.console |
Rolling buffer of recent console messages (last 200) |
browser.network |
Rolling buffer of recent network requests (last 200) |
browser.network_body |
Fetch the response body of one request by request_id |
browser.wait_for |
Wait for a selector / JS expression / network request URL to match a condition |
browser.wait_for_tab |
Wait for a popup/window.open tab spawned by a connected tab; auto-claims it for the caller |
browser.events |
Bridge lifecycle log (tab registered/disconnected/renamed/claimed/released, primary elected); paged with since_id |
Browser bridge — actions (most auto-claim the tab on first use — the two exceptions are footnoted):
| Tool | Purpose |
|---|---|
browser.navigate |
Send a tab to a different URL (waits for load by default) |
browser.click |
Click an element by CSS selector; hit-tests for occlusion first (force:true to bypass) |
browser.type |
Focus an input and type text via the native value setter |
browser.press |
Trusted CDP key press (+ modifiers) — real isTrusted:true input, for widgets synthetic events miss |
browser.drag |
Drag element→element or coordinate→coordinate; HTML5 or pointer-based, auto-detected |
browser.flow |
Run a declarative find/click/type/press/wait_for sequence in one round trip; fail-fast, max 20 steps |
browser.evaluate |
Run an arbitrary JavaScript expression in the page and return its result |
browser.dialog_respond |
Answer a pending native alert / confirm / prompt / beforeunload [^dialog-claim] |
browser.set_permission |
Pre-grant or pre-deny a browser permission (geo, notifications, camera, …) for an origin |
browser.claim_tab |
Reserve a tab cooperatively for the calling agent (multi-agent mode) |
browser.release_tab |
Release a tab claim the calling agent holds |
browser.reset |
Soft-reset the bridge — drop tabs, claims and events; keep the server alive [^reset-global] |
[^dialog-claim]:
dialog_respond deliberately bypasses the claim guard — a dialog
freezes the tab's JS thread, and unblocking it should not require
holding the claim. First responder wins.
[^reset-global]:
reset is global — it takes no tab_id and touches no claim; it
drops every tab, claim and event on the bridge at once.
click / type / press share a settle_ms option (see below). find,
click, type, press, drag (endpoint resolution only) and
snapshot/state all pierce open Shadow DOM roots and same-origin
iframes, nested arbitrarily.
Behavior worth knowing before you rely on it
| Behavior | What to know |
|---|---|
| Shadow DOM / iframe piercing | snapshot, find, state, click, type, press and drag's selector endpoints reach into OPEN shadow roots and same-origin iframes, nested arbitrarily. They CANNOT reach CLOSED shadow roots (attachShadow({mode:"closed"})) or cross-origin iframes — there is no CDP-level workaround for either. A selector that matches structurally-identical twins across roots comes back with ambiguous: true; use it immediately and never cache it in the map. |
| Occlusion guard | browser.click hit-tests the target's own click point before dispatching. If a different element covers that point, the call returns ok:false describing the blocker instead of clicking the wrong thing blindly. Pass force:true to bypass the guard intentionally. |
near_misses |
When browser.find matches nothing, the response can carry up to 3 ranked candidates as hints for a follow-up find call — they are suggestions for re-finding, never selectors to click on directly. |
browser.flow recipes |
The persistent map can store named, replayable flow recipes validated against the exact browser.flow step grammar, and the placeholder privacy rule that protects them — see Persistent UI map. |
settle_ms — settle proves QUIET, not EFFECT
click / type / press accept a settle_ms option (default 150 ms,
0 disables it): after dispatching the action they wait until the page
goes quiet — no DOM mutations for that many consecutive milliseconds —
and fold the "wait, then re-check" round trip into the action call
itself. The result carries a compact report:
{ settled, duration_ms, mutation_count, url_changed?, focus_moved?, reason? }
- Quiet is not effect. Mutations that finish before the observer
installs, and async reactions that start after the quiet window, are
both invisible —
mutation_count: 0withsettled: truedoes NOT prove the action had no effect. reason: "context-destroyed"means the action navigated the page, destroying the observer's execution context. The action itself still succeeded — this is a strong navigation signal, not an error.- Waiting for a specific expected condition (an element appearing, a
request completing) is
browser.wait_for's job, not settle's.
Persistent UI map — local-only memory across sessions:
| Tool | Purpose |
|---|---|
browser.map.recall |
Recall selectors / flow notes / gotchas / named flow recipes known for an app+route |
browser.map.save |
Persist a selector, flow note or gotcha, and/or one or more named replayable flow recipes |
browser.map.record_use |
Mark an entry as freshly verified or failed (not applicable to named flow recipes) |
browser.map.forget |
Delete an entry or an entire app |
browser.map.rename_app |
Fix an auto-derived app_key |
browser.map.apps |
List known apps |
On every MCP initialize handshake the server pushes a structured usage
protocol to the client (when to call recall, what kinds to save, what
to never save) — no manual prompt engineering required.
browser.list_tabs also surfaces the map directly: any tab whose origin
already has saved knowledge carries a map field ({ app_key, entries, flows }). The usage protocol above instructs the agent to call
browser.map.recall for that origin BEFORE taking a fresh browser.snapshot
— no separate lookup needed to know memory already exists for that page.
Persistent UI map
Every time the agent figures something out about a web app (where a button lives, which combination of events fires its handler, what gotcha tripped it the first time, or a whole multi-step path worth replaying later), it can persist that knowledge in a local SQLite database under your user folder. Next session, the agent calls
browser.map.recalland gets that knowledge back — instead of rediscovering the same selectors and flows from scratch every conversation. This is what makesbrowser-linkmore than a remote control.
What gets remembered
Two layers, both indexed by app:
Ad-hoc entries, indexed by (app, route) — three kinds:
| Kind | What it looks like | When the agent saves it |
|---|---|---|
| selector | { selector: "button[aria-label='Save']", evidence?: "found via snapshot" } |
A CSS selector tied to a stable purpose |
| flow | { steps: [{action:'click', selector:'#chip'}, {action:'wait', ms:500}, …] } |
A free-form note about a multi-step path — not browser.flow-replayable as-is |
| gotcha | { body: "Synthetic dblclick does not fire the React handler — use full sequence" } |
A non-obvious fact about the app that would take time to rediscover |
Named flow recipes (v0.18.0) — a separate flows table, one row per
named, directly browser.flow-replayable sequence, validated against
the exact browser.flow step grammar (find/click/type/press/wait_for)
before it is ever written. browser.map.save({ flows: [{ name, description?, steps }] }) upserts on (app, name); browser.map.recall
returns the app's saved recipes (name, description, steps,
use_count) alongside the entries above, unfiltered by route. The
intended loop: recall → adapt any placeholder text in steps (e.g.
type text "<QUERY>") to the real value the user asked for → call
browser.flow with the adapted steps. browser.map.record_use does not
apply to recipes — re-saving a recipe with the same name refreshes it
instead.
Placeholder privacy rule applies to both layers: never store domain
data (IDs, user names, dates, message content). Free text inside a
flow note or a flow recipe's steps uses a placeholder like <QUERY>
or <NAME>, never the value the user actually typed.
Each entry has verified_at / failed_at timestamps so the agent
knows whether the saved knowledge is fresh, stale, or known-broken.
When a selector that used to work suddenly fails, the agent marks it
via record_use({ ok: false }) and stops trusting it until it relearns.
The loop, in plain English
You ask:
"Open the user detail dialog for user 42 and check the audit log"
│
▼
1) Agent → browser.map.recall({ origin, url })
returns selectors / flow notes / gotchas / flow recipes it learned for this app
│
▼
2) Agent reuses what it knows — saves time and tokens
stale entries fall back to snapshot and relearn; wrong ones get marked;
a flow recipe gets its placeholders substituted, then replayed via browser.flow
│
▼
3) Agent does the task and saves any new learning via browser.map.save
so the next session starts even better-equipped
Schema
CREATE TABLE apps (
id, origin, app_key, title, notes, created_at, last_seen_at
);
CREATE TABLE entries (
id, app_id, url_pattern, kind, purpose,
payload TEXT, -- JSON blob, shape depends on kind
verified_at, failed_at, notes,
created_at, updated_at
);
CREATE TABLE flows (
id, app_id, name, description,
steps_json, -- ordered find/click/type/press/wait_for steps
use_count, created_at, updated_at
-- UNIQUE(app_id, name)
);
CLI: browser-link map
The six browser.map.* tools above are how an agent reads and writes
the map while it works. browser-link map is the same data from a
human's terminal — no agent required, useful for auditing what got
saved or pruning it after a refactor:
| Command | What it does |
|---|---|
browser-link map |
Table of every known app: app_key, origin, entry/flow counts, last seen (relative time) |
browser-link map show <app> |
Full detail for one app — entries (kind, purpose, payload snippet, verified/failed) and flows (name, description, step count, use_count). <app> resolves by app_key or by origin |
browser-link map forget <app> --flow <name> |
Delete one named flow recipe. The output names the resolved origin. No confirmation needed when <app> is unambiguous; if the same app_key exists on more than one origin, it prints which app it resolved to and asks for --yes (or pass the origin instead of the app_key to disambiguate) |
browser-link map forget <app> --yes |
Delete a whole app and everything under it. Without --yes, prints what would be deleted and the exact command to confirm — nothing is touched |
browser-link map export [<app>] [--out <file>] |
Export the whole map, or one app, as JSON (browser_link_map_export version field for forward compat). Defaults to stdout, so it composes in a pipeline |
browser-link map import <file> [--replace] |
Import an export. Default is a merge: apps upsert by canonical origin, flows upsert by name (a flow's use_count is never lowered), entries append and skip exact duplicates — each entry's verified_at/failed_at track record is preserved, never re-stamped. --replace deletes each imported app's existing data first. The WHOLE file is validated before anything is written — flow steps with the same rules browser.flow enforces, entry fields against the DB schema, a version check (newer exports are rejected cleanly), and sanity caps (500 apps / 5000 entries / 1000 flows per file, 1 MiB per payload) — any problem aborts the import with one aggregated report, inside a single transaction |
Not found → the error lists every known app_key so you don't have to
run browser-link map first to look one up.
Privacy note. An export can carry UI structure and flow steps an agent saved — selectors, notes, multi-step recipes. Review a file before sharing it. The placeholder rule above means the map should never contain real domain data, but an export is only as clean as what an agent actually saved into it.
Recording flows by demonstration
Instead of asking an agent to rediscover a multi-step UI path (open a
dialog, fill a form, submit), a human can demonstrate it once in a
connected tab and save the interaction directly as a named, replayable
browser.flow recipe — no agent involved in the capture at all.
STRICTLY OPT-IN, off by default. Nothing is ever recorded unless you explicitly turn the feature on AND press Record on a specific tab.
How to record:
- In the popup, check "Enable flow recording" (a settings toggle, same place and same persistence as the idle-disconnect TTL).
- On an already-connected tab, the popup now shows a Record button. Press it and demonstrate the task: click things, type into fields, use keyboard navigation.
- Press Stop. The popup shows a compact review — every captured step, in order.
- Name the recipe (e.g.
"open task detail dialog") and press Save — or Discard to throw the capture away. Save sends the recipe to the server, which validates it with the exact same rulesbrowser.flowitself enforces and writes it into the map'sflowstable (the same tablebrowser.map.save({ flows: [...] })writes to). A validation failure (e.g. too many steps) is shown in the popup instead of silently vanishing.
What gets captured — v1 grammar, mapping 1:1 onto browser.flow step
kinds: a click becomes {click:{selector}}; typing into a field becomes
ONE {type:{selector, text:"<TEXT>"}} step per field, emitted when you
move on (blur / Enter / change) — never per keystroke; Escape/Tab/
arrow keys outside a text field become {press:{key}}; a navigation
triggered by a step inserts an editable {wait_for:{expression: "document.readyState === 'complete'"}} suggestion. Capped at 20 steps —
recording auto-stops and the popup says so when the limit is hit. A step
whose selector could not be made unique across the page is flagged in the
review list (⚠ "may match multiple elements") and the caution is folded
into the saved recipe's description, so the signal follows the recipe.
Privacy properties — verifiable, not just promised:
- Recording is off by default; the setting lives in
chrome.storage.localand is also editable viabrowser-link config get/set flow-recording(same last-write-wins precedence as idle-ttl — see Idle-disconnect TTL above). - Only ever user-initiated, per tab: the recorder script is injected
via CDP
Runtime.evaluateonly after you press Record on that specific connected tab, and is removed (its listeners torn down in-page) the MOMENT recording stops — Stop, Discard, disconnect, tab removal, idle-disconnect, or the setting being turned off mid-recording all force this immediately. - A
typestep's payload structurally cannot carry what you typed — there is no field for it. The placeholder string"<TEXT>"is the only value ever written, the same convention hand-written flow recipes already use for free text (see the placeholder privacy rule above). There is no keystroke-level listener anywhere in the recorder. - Turning the popup toggle off stops recording instantly across every tab and discards whatever was captured so far — it does not sit around waiting to be saved.
Threat model — safe to leave enabled while browsing arbitrary pages. The recorder reaches the extension through a CDP binding, and a CDP binding is a global function reachable by EVERY script in the page (the site's own code, ads, third-party widgets, an XSS payload). Several layered defenses — not the review list — keep a hostile page from fabricating or smuggling steps:
- Per-session nonce + fully randomized globals. Each recording start
mints a fresh secret nonce and three independent random names for the
recorder's in-page globals (the CDP binding, an idle flag, the teardown
hook), all known only to the injected recorder. Every payload must carry
the right nonce or the extension discards it before parsing — so a page
script calling the binding blind records nothing. The global names are
bare random identifiers with no shared prefix or suffix, so a page cannot
cheaply scan
windowfor a predictable pattern to detect that recording is active either. - Trusted events only. Every recorder listener bails on
!event.isTrusted, so events a page dispatches programmatically (el.click(),dispatchEvent(new KeyboardEvent(...))) are never captured. Tradeoff: page code that activates its own widgets via.click()during a demonstration is not captured either — reproduce the underlying real gesture. - Gesture correlation for typed fields.
isTrustedalone is not enough here: unlike.click(), a programmaticelement.focus()/.blur()from page script DOES fire focus/blur events withisTrusted:true, so a hostile page could otherwise focus-then-blur an attacker-chosen input to fabricate atypestep. A type step is therefore recorded only when the field's focus was driven by a real user gesture — a trusted pointerdown/mousedown that landed on the field, or a trusted key event on it while focused. A field that focuses and blurs with no such gesture is dropped. - Stricter-than-agent validation. Recorder payloads are validated more
tightly than agent-authored
browser.flowsteps: selectors are length-capped and rejected on control characters (C0/C1 controls, plus Unicode zero-width/bidi-override/format characters), so the channel cannot smuggle a cookie or token disguised as a selector into the map; and apresskey must be on the exact recorder allowlist. The server also rejects aflow.recordedwhosetab_iddoes not match the submitting connection, and caps the recipe name/description length. - Honest residual risk. The nonce closes the realistic
blind-injection hole; it does not defend against a script that can read
the recorder closure's own variables — but any script with that power
already fully owns the page. The gesture-correlation gate is defeated
only in a much narrower way than the original zero-interaction bypass: a
page that structurally wraps a real, user-clickable decoy inside its
target field can get that field "gestured" by a genuine click, then force
the type-commit with a scripted blur — it still requires tricking the
user into a real click on attacker-controlled layout, not a
fully-silent fabrication. And the review list is a usability aid, not
a security control: do not rely on eyeballing 20 steps to spot one
fabricated entry — the nonce, the
isTrustedgate, and the gesture correlation are what actually prevent fabricated entries from ever reaching the list.
Known limitation. Single-page-app route changes done purely via the
History API (pushState/replaceState, no full document load) do NOT get
an automatic wait_for navigation hint — detection is based on Chrome's
tabs.onUpdated "complete" signal, which History-API navigations don't
fire. Add a wait_for step by hand to the recipe if replay needs to wait
for such a transition.
How agents replay a recorded flow: exactly like any other saved
recipe — browser.map.recall returns it under flows (name,
description, steps, use_count); the agent adapts any <TEXT>
placeholder to the real value the task calls for, then calls
browser.flow with the adapted steps. No new MCP tool surface — recording
only changes how a recipe gets INTO the map, not how an agent gets it out.
Customising
A handful of knobs — some on by default, all reversible.
Agent instructions
Having the MCP tools registered is necessary but not sufficient. Agents
reach for what their global instructions point at — and out of the box
they have no reason to call browser.snapshot when you say "the button
is broken". browser-link instructions install drops a fenced trigger
block into the agent's global instructions markdown:
| Client | File |
|---|---|
| Claude Code | ~/.claude/CLAUDE.md |
| OpenCode | ~/.config/opencode/AGENTS.md |
| GitHub Copilot CLI | ~/.copilot/AGENTS.md (override via COPILOT_HOME) |
The block is fenced by HTML-comment markers, so reinstall overwrites in
place and uninstall removes exactly the span we manage. The version stamp
in the begin marker lets future releases detect outdated blocks
(browser-link doctor shows ⚠ outdated until you re-run install).
Per-tool permissions
browser-link exposes 31 MCP tools — 25 bridge tools that drive the
connected Chrome tab (13 read, 12 action) and 6 to read/write the
persistent UI map (2 read, 4 write). All 31 are individually
toggle-able, so you can narrow the surface per machine:
- In the menu →
Permissions. Toggle individual tools with Space or apply a preset with Enter (all/readonly/no-eval/no-map). Press s to save. - From the shell:
browser-link tools # current state of all 31 tools
browser-link tools disable browser.evaluate # block JS execution
browser-link tools disable browser.reset # block destructive soft-reset
browser-link tools disable browser.set_permission # block permission grants
browser-link tools preset readonly # observation-only profile
browser-link tools enable browser.click # turn one back on
Presets, in plain English:
| Preset | What it disables |
|---|---|
all |
Nothing — every tool enabled (default). |
readonly |
The 12 non-read bridge tools (navigate / click / type / press / drag / flow / evaluate / dialog_respond / set_permission / claim_tab / release_tab / reset) and the 4 map-write tools (save / record_use / forget / rename_app). Leaves the 13 read bridge tools and the 2 map-read tools (recall / apps) — 15 tools total. |
no-eval |
Just browser.evaluate. Everything else stays on — useful for "agent can drive but cannot run arbitrary JS". |
no-map |
All 6 persistent-map tools. Bridge tools stay on. |
The deny list lives in config.json next to the map DB. Changes are
live: the server re-reads the file on every tools/list and
tools/call, so toggles take effect on the agent's next tool call — no
MCP client restart needed.
Multi-agent mode
Multi-agent mode is ON by default (multiAgent and autoReelect
both default to true). The first browser-link spawn becomes primary
and binds the WS port; a second browser-link spawn from another MCP
client automatically becomes a thin proxy that forwards MCP requests to
the primary over an internal IPC port, instead of failing with a clear
"port in use" error. Turn it off if you want a single MCP client to have
exclusive access:
browser-link multi-agent disable
browser-link multi-agent auto-reelect disable # optional, only relevant while multi-agent is on
(Or from the setup menu → Multi-agent.)
With it on, every client sees the same connected Chrome tabs and the
same persistent UI map. The IPC bridge listens on 127.0.0.1:17530
and applies the same kernel-level process-binding check as the WS port:
only Node-family binaries that present a fresh token from
config-dir/multi-agent-token are accepted.
Auto-reelect on primary close: if the primary's MCP client closes,
secondary proxies enter a 5-second reconnect window — in-flight
requests get a -32001 "temporarily unavailable" envelope while the
proxy waits for the new primary to bind the WS port. When it appears,
the proxy hot-swaps and traffic resumes.
Traceability — browser.events: every primary keeps an in-memory
ring buffer of bridge events (primary-elected, tab-registered,
tab-disconnected, tab-renamed, tab-claimed, tab-released,
tab-claim-rejected). When a tool call fails with "Tab not connected:
tab_X" the error message itself tells the agent to call
browser.events, where a tab-renamed entry maps the old id to the
new one — the agent recovers on its own. The Chrome extension
cooperates by remembering the last tab_id in chrome.storage.session
and asking the new primary to honour it on reconnect.
Claim registry — cooperative tab ownership: in multi-agent mode,
two agents touching the same tab can step on each other. Each tab
gets a soft owner via browser.claim_tab; the agent gets exclusive
access for an inactivity-based TTL and releases with
browser.release_tab. The primary sweeps stale claims so a crashed
agent never holds a tab forever. browser.my_tabs lists the tabs the
calling agent currently owns. Claims are advisory — they inform, they
do not block — so a single-client workflow never has to think about
them.
Idle-disconnect TTL
By default, a connected tab whose bridge sees no tool.request for 30
minutes gets automatically parked (WS closed, debugger detached — the
popup goes back to "Not connected"). This is configurable two ways,
and both edit the same logical setting:
From the extension popup — a small control below the Connect
button: Auto-disconnect idle tabs: [30 min ▾], with presets from 5
minutes up to 2 hours plus Never (disables auto-disconnect entirely).
Applies immediately — no extension reload needed. A CLI-set value
outside the preset list (say, 45) shows up as 45 min (custom) in the
dropdown rather than leaving it blank.
From the shell:
browser-link config get idle-ttl # show the current CLI-side value
browser-link config set idle-ttl 15 # 15 minutes
browser-link config set idle-ttl never # disable auto-disconnect
(Values outside 1-1440 minutes are clamped to the nearest bound; a non-numeric value is rejected with an error instead of being applied.)
Lowering the TTL applies against each tab's absolute last-activity time, not the moment you changed the setting — a tab that has already been idle longer than the new TTL disconnects on the next sweep (within about a minute).
Precedence — last write wins. The popup and the CLI both stamp an
updatedAt timestamp on every change. When a tab (re)connects, the
server sends its last CLI-set value (if any) to the extension; the
extension only applies it when it is newer than whatever was last
set locally (typically from the popup). If the CLI has never touched
the setting, the server never pushes anything, so a popup-only user is
never bothered. config set idle-ttl also tries to push the new value
to already-connected tabs immediately, over the multi-agent IPC
bridge (see above) — this requires a browser-link primary to be
running with multi-agent mode on (the default); otherwise the value is
saved and applies the next time a tab connects. Comparing raw
timestamps between the CLI (Node) and the extension (Chrome) is safe
here specifically because both always run on the same machine — this
is a loopback-only bridge, not a distributed system.
cdp-direct mode (no extension)
Off by default. An alternative, faster transport that lets the server
drive Chrome tabs directly over a Chrome launch flag
(--remote-debugging-port, default 9222), skipping the extension
entirely — no chrome.debugger attach, no popup, no per-tab "Connect this
tab" click. Useful when you already run Chrome headless/remote-debuggable
for other tooling and do not want to also load the extension.
Recording flows by demonstration is
popup-only — with no popup on a cdp: tab, there is nothing to press
Record on, so recording does not apply to cdp-direct; replaying an
already-saved recipe with browser.flow works the same on both transports.
The security tradeoff, plainly
The extension's model gives you two independent signals per tab: an explicit click ("Connect this tab") and Chrome's own yellow "started debugging this browser" infobar for as long as that tab is attached (see the FAQ above). cdp-direct has neither — once Chrome is listening on the debug port, any page target on it is drivable, with no per-tab consent step and no persistent on-tab indicator. That is a real reduction in transparency, not a cosmetic one, which is why cdp-direct requires you to opt in at TWO independent levels before any agent can touch it:
- The feature itself —
cdp-direct.enabled, off by default,config.json-persisted. - A live, time-boxed grant — a separate, short-lived permission you
issue explicitly and that expires on its own (default 60 minutes,
configurable,
neverallowed but discouraged).
An agent cannot flip either one — both are terminal-only commands. When a
tool call reaches a cdp: tab without both conditions met, it fails with
one of exactly two errors naming the missing step:
cdp-direct is disabled. The user can enable it with: browser-link config set cdp-direct.enabled true
cdp-direct requires an active grant. Ask the user to run: browser-link cdp allow
Two residual caveats worth stating plainly, in the same spirit as the security model's "malware already inside Chrome" caveat:
- Revoke does not interrupt a
browser.flowalready in progress. The gate is checked at the START of every tool call, including a flow; a flow that has already begun runs its remaining steps to completion even if you revoke mid-flow. The exposure is bounded to that one flow's worst-case budget (≤ 60 s by the flow step-budget cap) — after it returns, the next tool call sees the revoked grant and is refused. Every other tool call re-checks the gate fresh, so revoke takes effect immediately for everything except an in-flight flow. - Port squatting. browser-link verifies the debug endpoint is really
Chrome (its
/json/versionBrowserstring must contain"Chrome") before trusting anything on it — but a local process that manages to bind the debug port before Chrome does could present that string and impersonate the endpoint. The blast radius is data spoofing (feeding the agent fake page content over loopback), not server RCE, and it requires an attacker who already has local code execution on your machine — the same precondition under which the extension transport's own process-binding check is likewise moot. Loopback-only binding means no remote party can reach the port regardless.
Enable → allow → use → revoke
# 1. Launch Chrome with the remote-debugging port open (you do this — browser-link never launches Chrome).
google-chrome --remote-debugging-port=9222 # Linux
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 # Windows
open -a "Google Chrome" --args --remote-debugging-port=9222 # macOS
# 2. Turn the feature on (persists in config.json; still not usable without a grant).
browser-link config set cdp-direct.enabled true
# 3. Grant time-boxed access. Only you can run this — no agent can grant itself access.
browser-link cdp allow # uses cdp-direct.grant-ttl (default 60 min)
browser-link cdp allow --minutes 15 # a shorter one-off grant
browser-link cdp allow --minutes never # never expires — reduces the security posture, see above
# 4. Check state at any time.
browser-link cdp status # enabled?, port, grant remaining, endpoint reachable?
# 5. Revoke whenever you want the door shut again.
browser-link cdp revoke
Once both conditions hold, browser.list_tabs also returns cdp-direct
page targets — real Chrome tabs, filtered to exclude devtools:// and
chrome-extension:// pages — with tab_id values shaped cdp:<targetId>
and transport: "cdp", alongside your normal extension tabs (which keep
working exactly as before, unaffected). Claims, the persistent map's
origin-keyed hints, and every other cross-cutting feature work on a cdp:
tab the same as an extension tab.
browser-link config get cdp-direct.enabled | cdp-direct.port | cdp-direct.grant-ttl
browser-link config set cdp-direct.port 9333 # loopback port only — the host is always 127.0.0.1
browser-link config set cdp-direct.grant-ttl 15 # default TTL future `cdp allow` calls use
v1 tool support
Every tool that reaches into page/JS content works identically over
cdp-direct — same in-page logic, same CDP commands (Runtime.evaluate,
Input.dispatchMouseEvent/dispatchKeyEvent, Page.navigate). A handful
of tools that depend on extension-only state (buffered console/network
history, canvas capture wiring, native permission grants, the popup's
dialog channel, or window.open tab-creation events) are explicitly out of
v1 scope and return a clear error naming the extension as the fallback.
| Supported in v1 | NOT supported in v1 (use the extension instead) |
|---|---|
list_tabs, ping, snapshot, find, state, click, type, press, evaluate, wait_for*, navigate, flow |
console, network, network_body, canvas_screenshot, dialog_respond, set_permission, wait_for_tab, drag |
* wait_for's network_url mode also falls back to "use the extension"
— cdp-direct does not buffer network requests, so there is nothing to poll
against. selector and expression modes are fully supported.
Claims apply to cdp: tab_ids through the same registry extension tabs
use. See the CHANGELOG's v0.23.0 entry for what a future v2 might add
(console/network mirroring, canvas capture, drag).
Security model
The WebSocket bridge binds to 127.0.0.1:17529 — loopback only, never
on a public interface. On top of that, before accepting any WebSocket
handshake the server asks the operating-system kernel which process
opened the incoming TCP connection. If the owning binary is not a known
Chromium-based browser (Chrome, Chromium, Edge, Brave, Vivaldi) the
handshake is refused with HTTP 403 before any application bytes are
exchanged.
- macOS / Linux →
lsof(/proc/net/tcpon Linux is enough too). - Windows →
netstat -anolocates the owning PID; the process name is then resolved via PowerShellGet-Process, withtasklistas the fallback when PowerShell is missing, locked down, or slow.
Concretely this means:
- ✔ Random local processes (curl, other Node scripts, scanners) cannot talk to the bridge even if they figured out the port.
- ✔ A process that crafts a fake
Origin: chrome-extension://...header is still rejected: the kernel reports its real binary name. - ✘ Malware that has already injected itself inside Chrome (via
chrome.debuggerfrom another extension, dylib injection, gdb attach…) passes the check. But that attacker already controls the browser directly — the bridge gives them nothing they did not already have.
No tokens to paste, no manifests to register, no manual step beyond
clicking "Connect this tab" in the extension popup. browser-link doctor
lists the current allowlist on your OS.
Where your data lives
The persistent map is a single SQLite file (map.db) on your
machine, never uploaded:
| OS | Path |
|---|---|
| macOS | ~/Library/Application Support/browser-link/map.db |
| Linux | $XDG_DATA_HOME/browser-link/map.db (default ~/.local/share/browser-link/map.db) |
| Windows | %APPDATA%\browser-link\map.db |
Override with BROWSER_LINK_DATA_DIR if you want a portable install
or need to inspect the DB out-of-the-way. The same directory holds
config.json (UX preferences, per-tool permissions, and the CLI-side
idle-disconnect TTL) and multi-agent-token (rotated at every primary
startup).
Nothing in this package phones home. The WebSocket bridge talks loopback only.
For contributors
Open project, contributions welcome. Code is the quickest way, but bug reports, repro cases, and ideas in the issue tracker are just as useful.
- Bug or feature idea: open an issue at github.com/jobshimo/browser-link/issues.
- Pull request: fork, branch from
main, push, open a PR. All merges go through review.
Repository layout
browser-link/
├── .github/
│ ├── dependabot.yml # grouped weekly dependency updates + self-heal workflow
│ └── workflows/ # ci.yml, codeql.yml, version-gate.yml, dependabot-version-bump.yml
├── packages/
│ ├── server/ # MCP server + CLI binary published as @jobshimo/browser-link
│ ├── extension/ # Manifest V3 Chrome extension, bundled into the npm tarball
│ └── shared/ # workspace-internal type-only package
├── scripts/
│ ├── release.mjs # local release engine: bump versions + open the release PR
│ ├── version-gate.mjs # CI gate: blocks a PR whose versions are not aligned
│ ├── dependabot-bump-version.mjs # self-heal a Dependabot PR that failed the gate
│ └── lib/versions.mjs # shared version utilities (VERSIONED_FILES, semver helpers)
├── LICENSE
├── README.md # this file
└── DECISIONS.md # living architecture / design-decision log
Development setup
Requires Node ≥ 22.13 and pnpm 11+. The exact pnpm version is
pinned in package.json via the packageManager field — corepack
(bundled with Node ≥ 16) reads that field and uses the matching pnpm
version automatically.
git clone https://github.com/jobshimo/browser-link.git
cd browser-link
corepack enable # one-time, picks up the pinned pnpm version
pnpm install
pnpm run build
| Script | What it does |
|---|---|
pnpm run build |
Build the server and the Chrome extension |
pnpm run build:server |
Build only the server (packages/server/dist/) |
pnpm run build:extension |
Build only the extension (packages/extension/dist/) |
pnpm run dev |
Run the server in watch mode (recompiles on save) |
pnpm run try |
Run the TUI directly from source via tsx |
pnpm run typecheck |
Type-check every workspace, no emit |
pnpm run test |
Run every workspace's Vitest suite (server + extension) |
pnpm run inspect |
Launch the MCP Inspector wired to the local server |
pnpm run generate:icons |
Regenerate extension PNGs from icons/icon.svg |
pnpm run clean |
Remove every dist/ directory |
Both packages ship their own Vitest suite: the server's runs under plain
Node, the extension's runs under jsdom (popup, background service
worker, and the in-page selector/click/settle builders are all
unit-tested by evaluating the exact shipped JS strings). Run them
together with pnpm run test, or scope to one package with
pnpm --filter @jobshimo/browser-link run test /
pnpm --filter @browser-link/extension run test.
⚠️
pnpm run devconflicts with a running MCP client
pnpm run devbinds the same127.0.0.1:17529the registered MCP server uses. Two processes cannot bind the same port at the same time. While developing locally:
- If your MCP client (Claude Code, OpenCode, …) is open with
browser-linkregistered, it already spawned the server and owns the port.pnpm run devwill crash withEADDRINUSE.- If
pnpm run devis holding the port, the client'sbrowser-linkMCP will fail to start.Recommended dev flow: quit the MCP client (or
browser-link stopto kill the spawn it left holding the port) → runpnpm run dev→ when done, stoppnpm run devand reopen the client so it can spawn its own server.pnpm run build(no watch) does not touch the port, so you can always rebuild while the client is open.
Architecture decisions are kept in DECISIONS.md.
Cutting a release
Hard rule enforced by CI: every PR merged into main MUST bump the
version, and the five versioned files in the monorepo (root, server,
extension package.json, extension manifest.json, shared) MUST agree
on the same number. The Version Gate workflow blocks any PR that
doesn't comply and is a required check on main. Every merge to
main is a release.
pnpm run release -- patch # 0.7.0 → 0.7.1
pnpm run release -- minor # 0.7.0 → 0.8.0
pnpm run release -- major # 0.7.0 → 1.0.0
pnpm run release -- 0.7.1 # explicit version
What scripts/release.mjs does, in order:
- Refuses to start unless your working tree is clean, you are on
main, andmainis in sync withorigin/main. - Refuses to start unless every
versionfield across the monorepo is already aligned. - Bumps every version field to the new number.
- Runs
pnpm install --lockfile-onlysopnpm-lock.yamlmatches. - Generates a CHANGELOG entry at the top of
packages/server/CHANGELOG.mdfrom conventional commits since the previous tag, grouped by section. - Commits the lot on a new branch
release/vX.Y.Zand pushes it. - Opens a PR against
mainwith the CHANGELOG entry in the body.
You then review the PR and merge it via the GitHub UI. On merge, the
release job in .github/workflows/ci.yml:
- Reads the version from
packages/server/package.json. - Creates the tag
vX.Y.Zand the matching GitHub Release. - Publishes
@jobshimo/[email protected]to npm via OIDC Trusted Publisher — noNPM_TOKENstored anywhere, the publish credentials are short-lived and granted per-run by GitHub Actions.
The job is idempotent: if the release / tag for that version already exists, those steps are skipped.
Author
Martín Miguel Bernal — github.com/jobshimo
License
MIT — © 2026 Martín Miguel Bernal
Установить Browser Link в Claude Desktop, Claude Code, Cursor
unyly install browser-linkСтавит в Claude Desktop, Claude Code, Cursor и VS Code — сам разбирается с npx, uvx и сборкой из исходников.
Впервые? Поставь CLI: curl -fsSL https://unyly.org/install | sh
Или настроить вручную
Выполни в терминале:
claude mcp add browser-link -- npx -y @jobshimo/browser-linkFAQ
Browser Link MCP бесплатный?
Да, Browser Link MCP бесплатный — установка в пару кликов через Unyly без оплаты.
Нужен ли API-ключ для Browser Link?
Нет, Browser Link работает без API-ключей и переменных окружения.
Browser Link — hosted или self-hosted?
Self-hosted: сервер запускается локально на твоей машине командой из раздела установки.
Как установить Browser Link в Claude Desktop, Claude Code или Cursor?
Открой Browser Link на unyly.org, выбери вкладку своего клиента (Claude Desktop, Claude Code, Cursor) и нажми Install — конфиг сгенерируется автоматически, без правки JSON.
Похожие MCP
GitHub
PRs, issues, code search, CI status
автор: GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
автор: mcpdotdirectCompare Browser Link with
Не уверен что выбрать?
Найди свой стек за 60 секунд
Автор?
Embed-бейдж для README
Похожее
Все в категории development
