Repository navigation
Releases: RandyNorthrup/chrome-control-mcp
Release list
Chrome Control MCP v1.7.0
Chrome Control MCP 1.7.0 fixes the three things 1.6.1 shipped without fixing, and the reason all
three had survived: not one of them had a test that could fail. It also fixes something nobody had
filed, because the people it bothered would sooner turn the extension off than report it.
browser_scroll reported 0 for a container it had just moved
The two reads either side of the wheel matched scrollers by name, and the name counted ancestors
upward from whatever element the point happened to land on -- scroller-2:DIV. That number belongs
to the content under the point, not to the container. Scroll the content and the point lands
somewhere else inside the same scroller -- on its padding, on a taller row, on a child one level
deeper -- so the container that had just moved came back under a different name. scrollDistance
matched by name, found nothing from before, and reported scrolled 0 for a scroll it had performed.
The in-page rest detector compared the same list and saw a change that was only the renaming.
Scrollers are named by their path from the document root now. What is under the point may change;
where the container sits in the document does not. The live fixture's scroll region gained rows of
differing depth so the rename actually happens there, and the suite checks the distance the reply
claims against the measured movement of an element inside it.
That last part took two attempts. The first version measured across the same call that reported the
distance -- but aiming at a ref reveals it first, and on that call the reveal scrolled the page 726px
before the wheel moved the container 180px. Both numbers were right; the comparison was measuring
two scrolls against one. It measures across a second scroll now, whose reveal has nothing left to do.
Elements the page wires up itself had no ref
A real page builds most of its controls out of plain elements: a <div> with a click handler, a card
that is clickable because it is styled cursor: pointer. None of that reaches the accessibility
tree. Those arrive as generic with no name and nothing saying they can be acted on, so the capture
dropped them -- the model was shown scenery where the page had a button, and the only way to reach
one was browser_click_at with a pixel read off a screenshot.
The capture reads three signals out of the DOM snapshot it already takes, so this costs no extra
round trip:
| Flag | What earned it |
|---|---|
(clickable) |
Chrome's own isClickable |
(hoverable) |
a pointer handler attribute on the element |
(pointer) |
its computed cursor is pointer and its parent's is not |
Both halves of that were measured against a real Chrome before anything was built on them, and both
mattered. isClickable flags an inline onclick and an addEventListener wiring, which no
attribute scan could see -- but it does not flag a hover-only handler, which is why the attribute
scan is still there. And cursor is inherited: a child inside a pointer card computes pointer
itself, so promoting on the computed value alone would have turned one clickable card into a ref for
every part of it. Comparing against the parent is what keeps the ref on the card.
An element laid out to nothing gets no ref however it is wired. There is nothing to aim at.
browser did not reply within 30000 ms (connection reset)
A silence budget was being spent as a work budget. Handlers are allowed far longer than 30 s by
design -- browser_download polls to 120 s -- so a slow page, a big download or a loaded machine
surfaced as a transport failure and a reset connection for work that was proceeding normally.
The extension reports progress every 5 seconds while a command runs. The relay forwards those beats
without mistaking one for the reply, and each restarts the server's deadline. Silence still fails in
30 seconds. A command that beats but never finishes is abandoned at 180 s with a refusal that names
it and says it ran too long, instead of blaming a connection that is fine.
The beat earns its keep twice: posting on the port is activity, so the same beat that reports the
work keeps the MV3 service worker doing it alive.
Deciding which of the two budgets ran out turned out not to be a clock comparison. The deadline is
built from whole milliseconds of remaining time, so a read cut short by the ceiling expires a
fraction before it -- measured on Linux, seven beats in -- and a clock check then says the
ceiling had not been reached. It is decided by which budget sized the read instead.
And the half the beats could not reach
CI then lost a 5120x2880 screenshot to the same message the beats were built to prevent. The beats
were not at fault and were not even involved: once the extension starts posting a reply there are no
more beats to send, and the stall was in the transfer itself. The read loop handed every chunk of an
incoming frame the same shrinking deadline, so the budget bounded the whole transfer rather than a
gap in it -- a reply of a few megabytes crossing a loaded machine simply ran out of time while
arriving perfectly steadily.
A chunk that moved bytes now restarts the budget, bounded by the same ceiling on the whole exchange.
Silence still fails in 30 seconds. The regression test dribbles one frame across in ten pieces over
three times the budget and requires it whole.
One thing fell out of that. browser_wait_for clamped timeout_ms to 25 seconds, and its comment
said exactly why: the whole exchange lived under one 30-second deadline, so a wait allowed to outlast
it left the caller reading a transport reset while the handler kept polling into a relay that was
already gone. That was the right workaround for the budget as it was. The budget restarts now, so the
cap is the same 120 seconds browser_download uses -- and the description says so, which it did not
when a caller asking for 60 seconds silently got 25.
A scroll could report that it had settled before it had begun
Reported twice by CI, on two platforms and two configurations, and both reports carried the same
arithmetic: the reply said scrolled 0, settled true, and the page then moved by exactly the amount
the following call turned out to be wrong by.
"Nothing moved" was treated as "an edge already reached, so there is nothing to come to rest". That
is true at an edge and wrong when the wheel simply has not been acted on yet -- and on a heavily
zoomed page that is the ordinary case, because at 25% zoom a request of 200 is 800 CSS pixels of
animated travel, which does not finish inside the window the reply is built in.
room already told the two apart and was already in the reply. Nothing moved with room left now
waits to find out; nothing moved with no room is still answered immediately, so an edge scroll is no
slower. Waiting needed a new helper: settleScroll waits for the offsets to stop changing, and
before a scroll begins they already have -- its first two reads agree and it returns at once, having
measured nothing.
A scroll could report that it had settled before it had
The rest detector resolved when the serialized offsets differed from the ones read before the
wheel. But the chain under the point changes composition as content moves, so a scroller entering or
leaving it made a different list without anything having moved -- while scrollDistance, which
matches only the names present in both reads, correctly reported 0.
So browser_scroll could answer settled: true, scrolled: 0 and the page would go on moving after
the reply had been sent, leaving the next call to read a stale position. Both now measure by one
rule, in one function that travels into the page beside the reader that produces the offsets.
The extension asked for a bridge that was not there, forever
Nobody filed this one. Every connection attempt starts a process: connectNative makes Chrome launch
the native host, which looks for a published server, finds none, says so and exits. The retry was a
flat 2 seconds re-armed by its own disconnect, with one further attempt on every tabs.onUpdated and
tabs.onActivated -- so a machine with the extension installed and no editor running spawned host
processes continuously, and faster while its owner was browsing.
The interval now doubles while nothing answers, from 2 seconds to a 2-minute ceiling, and resets the
moment a bridge answers. A server that is running is still found within two seconds. The first retry
after a failure is still fast, because a server that has only just gone may be a moment from coming
back; it is the run of failures after that which earns the waiting. The tab events and the alarm are
held to the same interval, which is the part that matters -- they fire far more often than any timer,
and they were never subject to the old one.
Backing off is not the same as being able to stop, so the toolbar button is now a switch. Click it
while the bridge is connected and it pauses: the ports close, the browser is released, and nothing
reaches for it again until you resume. Click while paused and it resumes and connects at once; click
while it is merely not connected and it retries immediately rather than waiting out the interval. The
badge reads off while paused and shows nothing otherwise. The state survives a browser restart --
somebody who turned it off because it was in their way should not find it back on tomorrow.
This adds the storage permission.
Measured rather than asserted: tools/backoff_probe.mjs loads the extension in a real browser with
no server to find and counts the attempts. On a 75-second watch it records 6, across three
service-worker restarts, with no exceptions -- where the retry it replaces would have made at least
37 in the same window, plus one for every tab event on top.
`browse...
Chrome Control MCP v1.6.1
Chrome Control MCP 1.6.1 fixes everything a fresh-install run on a clean Windows
11 VM turned up: an agent installed 1.6.0 there and drove 50 of the 51 tools
against live sites, checking each one's effect rather than its reply. Six faults
and three inaccurate descriptions came back. This is all of them.
browser_network dropped redirects
A redirect re-fires Network.requestWillBeSent under the same request id, and
the pending entry was overwritten without ever being logged. A form POST that 302s
appeared only as the GET that followed — the exact opposite of the one thing the
tool exists for, confirming that a form actually posted.
Both hops are logged now. The one that redirected carries its own status and a
redirected_to naming where it went; the request it landed on stays its own
entry. The E2E fixture gained a POST /api/redirect → /api/landed route and the
live suite asserts both.
browser_select could not match a label from the snapshot
Plenty of pages write between words for alignment. That puts U+00A0 in
option.text while the snapshot renders an ordinary space, and a === comparison
makes those two different strings — so "Los Angeles", copied straight out of the
snapshot, never matched the option literally called that. Both the single and the
multi-select paths now compare with whitespace collapsed.
browser_permission passed a value Chrome rejects
javascript, images and popups are on-or-off: there is no prompt to show, so
the API refuses ask rather than treating it as a default. The tool handed it
straight through, and what came back was:
Invalid invocation: Error at property 'setting': Value must be one of allow, block.
That names neither the tool, nor the permission, nor what would have worked. The
per-type table is checked before the call now, and the refusal says all three. It
also states the limitation honestly: Chrome's extension API cannot return one
site to the browser default — that belongs in chrome://settings/content.
browser_media could not reach some videos
A <video> whose controls the page draws itself is not focusable and carries no
accessibility value, so it earned no ref and reached the model as scenery it could
see and not touch — while the video beside it, which happened to be focusable,
worked. That is why it looked arbitrary. video and audio are interactable
roles now, which is what a tool built to drive them requires.
browser_hover could not reach an element without a ref
A link with no href, or a div with only an onmouseenter handler, has no
accessibility role that says interactable. browser_click_at already answered that
for clicks by taking a pixel off the last screenshot; hover had no such path, so
those elements were clickable and not hoverable.
It takes x/y on the same terms now — refused unless the screenshot they came
from still matches the live render — and refuses a call that gives both a ref and
a point.
Three descriptions that did not match the code
- The recording timeline's field is
cmd; the description called itname. browser_backcan fail after abrowser_navigatenobody clicked on, because
Chrome creates no history entry for a navigation with no user interaction. That
refusal is Chrome's, and the description now says so instead of leaving it
looking like a fault.browser_extension_installtold every caller to load the extension by hand even
when it was already loaded and connected. It now says which case it means.
A failure that could not be read
The Linux display matrix died with SyntaxError: Unexpected token 'b', "browser di"... is not valid JSON — a tool that failed under load being handed to
JSON.parse. A reply that is not JSON now reports what the tool actually
answered. This does not stop the underlying reply-budget timeout; it makes the
next one legible.
Upgrading
browser_update_apply, then restart the MCP server in your client and reload the
extension at chrome://extensions.
Chrome Control MCP v1.6.0
Chrome Control MCP 1.6.0 lets a session see what the page said and what it
fetched, runs the live browser suites on Windows for the first time, and gives
recording the end-to-end coverage it shipped without.
A session could not see why anything failed
An agent that clicked a button and saw nothing happen had no way to learn that
the page threw TypeError: x is not a function. That one fact usually explains
it, and nothing in the toolset could reach it.
browser_console reports the page's own console output, its uncaught
exceptions and unhandled rejections, and what the browser said about the page —
a blocked mixed-content load, a CSP violation, a subresource that failed. That
last group never reaches the page's console API at all, so without it a page that
simply does not work gives no reason why.
browser_network reports the requests the page made: method, URL, resource
type, status, MIME type, and the milliseconds until the response arrived. A
request that never got a response carries the browser's error text instead of a
status, because a transport failure has no status and 0 would read as a server
answering 0. The Network domain was already enabled and in-flight requests were
already tracked — for wait_for network_idle — but none of it had ever been
surfaced, so nothing could confirm that a form actually posted.
Both are bounded rings of bounded entries, because every field in them is written
by the site. Each reply separates what it left out (omitted) from what the
buffer discarded (dropped): an empty list means "the page was quiet" in one case
and "the errors scrolled off" in the other, and those lead somewhere different.
browser_network also reports in_flight, so an empty list is not read as a page
that asked for nothing.
Neither can change what the page does or what the user sees, so both are in the
read-only profile.
Why there is still no JavaScript evaluation tool
It is the obvious thing to add, and it is written down in docs/TOOLS.md and
docs/SECURITY.md as a decision rather than left as an omission.
An arbitrary-evaluation tool would void most of the defenses in SECURITY.md in
one call: it reads and writes storage outside the isolated world that verifies the
live origin, redefines window.confirm past the dialog policy, clicks with none
of the freshness gating that [ref] and coordinate actions require, answers the
refusal of javascript: URLs through another door, and can delete the AI CONTROL
overlay that is the user's only signal a session is driving the tab.
It cannot be narrowed into safety either: there is no read-only
Runtime.evaluate, so a "read-only eval" would be a promise the code could not
keep. browser_console and browser_network answer what evaluation is usually
reached for. browser_js_click remains the one narrow escape hatch.
The live suites run on Windows
They ran on macOS alone. Linux is excluded because that runner never answers a
capture under an emulated fractional scale; Windows was simply never in the
matrix.
Windows is where the versioned install layout lives — current is a directory
junction onto versions/<version> — and that is exactly what shipped broken in
1.4.0 and again in 1.5.0, with green unit tests throughout, because nothing
ever drove a live browser there. The job registers the native-messaging host for
the build under test, since Windows keeps one such key per user and the dedicated
browser reaches whatever it names.
Getting it running took one real fix: the job configured with -G Ninja and no
MSVC environment, so CMake took the first c++ on the runner's PATH — MinGW —
and the build died on -Wsign-conversion in code MSVC compiles silently. It now
asks for the Visual Studio generator, as the existing build job already did.
Recording has live coverage
It shipped in 1.5.0 and the suite said so in its own NOT_EXERCISED_HERE:
"needs its own live coverage; not written yet". That is how a start which failed
on its first real call — captureStream on an OffscreenCanvas, which has none —
reached a release. The case now records, checks that a second start is refused,
stops, and asserts the file is not the zero-byte one a recorder that captured
nothing writes.
Nothing in NOT_EXERCISED_HERE now says "not written yet".
Three bugs the new coverage found
None of these could have been caught by a unit test.
The network log filled with icons. The media element's controls load a data:
URL per button, and they crowded out every request the page actually made — the
first live run saw a log that was nothing but base64 SVG. Inline content is bytes
the page already had: no connection, no server, no status worth reporting. It is
still counted for network_idle, which must wait for it, and left out of the log.
Entries were written too late. Recording at loadingFinished lost the
document's own request every time, because that request completes the navigation
which drops anything still awaiting a response — and a fetch() whose body is
never read never finishes loading at all, so it sat in flight for the life of the
session. Entries are now written when the response arrives, which fixes both, and
ms became what the server took rather than what the body cost to stream.
A fixture left a request outstanding, and wait_for network_idle correctly
refused to report idle three cases later. That one was the product catching the
test.
Also
The control cursor is 24px rather than 32px. At that size the SVG renders at scale
1, so the arrow's tip lands on exact integers instead of the rounding the old size
needed, and the cheap position-only update and the full overlay build now share
one offset — a test asserts they agree, because which of them draws depends only
on whether the overlay already existed.
Upgrading
browser_update_apply, then restart the MCP server in your client and reload the
extension at chrome://extensions.
Chrome Control MCP v1.5.5
Chrome Control MCP 1.5.5 fixes a drag that could take the extension's service
worker down with it, and puts a gate under the version drift that had been
shipping unnoticed since 1.4.0.
A failed drag could stop the session answering
A drag sends every step's commands before it awaits any of them. That is
deliberate: a mouse move is a continuous event that Chrome answers at a frame,
and awaiting each one in turn measured 8.9 s for a single drag in a background
tab, against 0.23 s in a foreground one.
The cost of doing it that way was never paid for. A command that rejects while an
earlier step is still being awaited has no handler attached yet, and V8 raises
unhandledrejection at the next microtask checkpoint — which, in a service
worker, is something the runtime may tear the worker down over. The drag then
stops answering, and the bridge reports what it can see:
browser_drag failed: browser did not reply within 30000 ms (connection reset)
That names the transport for a fault in the handler. The macOS display matrix
failed exactly this way.
Each in-flight command is now marked handled the moment it is created. The
originals are still awaited in order, so a real mid-drag failure still stops the
drag where it stopped and still releases the mouse button where the pointer
actually got to.
A drag rebuilt the whole cursor overlay on every step
Positioning the "AI CONTROL" cursor re-sent the script that builds the entire
overlay: three getElementById lookups, a stylesheet check, two cssText
assignments and an innerHTML that reparses an SVG. That is the right thing to
do once. A drag did it on every step, up to 60 of them, for the sake of two
numbers.
There is now a position-only nudge that moves a cursor already on the page and
reports false when there is none, with the full build kept as the fallback for
a fresh document. A drag had already come within the bridge's 30 s reply budget
on a loaded runner; this was the per-step cost inside it.
Both lockfiles said 1.3.1
package-lock.json and vscode/package-lock.json named version 1.3.1 while the
project shipped 1.4.0, 1.5.0, 1.5.1, 1.5.2, 1.5.3 and 1.5.4. Five releases went
out carrying a lockfile from before any of them.
Nothing caught it because nothing had ever compared the places a version is
written, and npm does not do it either: npm ci validates dependencies against
the lock, not the root version, so the drift is silent by construction. The
release checklist was a person remembering six files.
tests/test_release_versions.mjs is that checklist, run by the gate. Every file
that states a version must state the same one — CMake, both package.jsons, both
lockfiles, the extension manifest and the installer header — and the version being
shipped must have a changelog entry and a release note to go with it.
Upgrading
browser_update_apply, then restart the MCP server in your client and reload the
extension at chrome://extensions.
Chrome Control MCP v1.5.4
Chrome Control MCP 1.5.4 fixes a fault that could make any tool taking a [ref]
act on the element beside the one the model named, and report success.
A ref named a position, not a node
browser_snapshot advertises "a stable [ref]". It was not stable. Refs were
numbered by document order and renumbered from scratch on every snapshot, so an
element that gained a ref-worthy sibling above it answered to a different ref
the next time the page was captured. Every ref a model was still holding went on
resolving — silently, and to the element next door.
The header said as much without noticing: interactable nodes got "a stable eN
ref in document order". Those two halves contradict each other. In document order
is exactly what makes a ref unstable. The advertised contract was the honest half,
so the code now meets it.
This reached browser_click, browser_type, browser_drag, browser_scroll —
anything resolving a ref. A click could land on the wrong control and the reply
would say it worked.
What changed
The previous snapshot's index is carried into the next one by backendNodeId. A
node seen before keeps the ref it was issued; only a genuinely new node takes a
new number; and a node that has gone takes its ref with it rather than leaving it
for whoever stands in that position next. Nothing is carried across a navigation,
where a backendNodeId no longer names the same node.
How it was found
The live suite's nested-region scroll had been failing on macOS for several
releases, reporting scrolled: {x:0, y:0} — which a caller could not tell from
"already at the edge". Three plausible explanations were tested and killed:
- The wheel does not take on macOS. A pure-CDP probe on the macOS runner moved
the region 180px in 25 of 25 runs, across five dispatch shapes. - The box is read while the page is still scrolling. Zero drift between
DOM.scrollIntoViewIfNeededand the box read that follows it, on every platform. - The page moved under the point. The point was byte-identical before and
after re-resolving it.
What found it was making the reply carry evidence instead of a verdict. Once
browser_scroll reported what the wheel landed on and how much travel was under
it, the first failure said hit: "DIV#shadow-host" and room: 0 — and the ref's
own box came back 131×15, which is the fixture's shadow button. The scroll
region is 1021×126. The suite had been driving the wrong element all along.
Also fixed
An action could be aimed at where an element used to be. Bringing an element
into view moves the page, and that move is not always finished when the command
that asked for it returns. resolveActionQuad now waits for the page to come to
rest before reading the box. Shared by click, hover, drag and reveal.
A scroll by [ref] now checks that its point still lands on the element. If
the point has slipped, the box is taken again; if it has slipped twice, the scroll
is refused and names what it hit, rather than wheeling a stranger and reporting the
element that was asked for. The hit test climbs shadow boundaries: elementFromPoint
reports the host for a point inside a shadow tree and Node.contains stops at
the boundary, so a plain containment test calls every element in a shadow root a
miss.
New in the reply
browser_scroll now returns point, hit and room alongside scrolled.
scrolled 0 with room 0 is an edge already reached, and an answer. scrolled 0
with room left means the wheel never reached the scroller, which is a fault worth
reporting rather than an answer.
Also in this release
tools/scroll_probe.mjs, the diagnostic that ruled out the wheel and the protocol.
It is pure CDP — no MCP server, no extension, no build — so it runs in about two
minutes wherever a platform disagrees about input.
Upgrading
browser_update_apply, then restart the MCP server in your client and reload the
extension at chrome://extensions.
Chrome Control MCP v1.5.3
Chrome Control MCP 1.5.3 corrects what the recording tool tells an assistant, and
brings the README in line with what the code actually does. No behaviour changes.
A sentence that was never true
browser_record_start described itself to models like this:
Starting a second recording for the same session is refused; stop the first.
Chrome shows its own recording indicator while this runs.
The last sentence was wrong on the path that shipped. It survived from the
chrome.tabCapture design, which was ruled out before any code was written:
tabCapture needs the activeTab grant, and that comes only from a user invoking
the extension on that specific tab, which an agent-driven start has no way to
produce.
What shipped instead is CDP Page.startScreencast on the debugger the session
already holds. That raises no recording indicator. What Chrome shows is the
debugging banner, and it is up for the whole session whether or not anything is
being recorded.
docs/SECURITY.md had this right — it says Chrome "displays its own indicator
while a tab is being debugged" — but the description a model reads did not, and
that is the one an assistant would repeat to a user.
The README
A claim the code does not make. The capability table said a session "never
takes your OS focus". It never asks for focus, which is not the same
statement. docs/VERIFICATION.md has recorded since 2026-09-18 that Hyprland
focuses a newly mapped window regardless, and browser_window new reports that
as took_os_focus precisely so it is not silent. The table now says what the
code promises and names the case where the compositor overrules it.
Two capabilities that were listed and never explained. Multi-session and
recording appeared in the capability table and nowhere else. The README now
says how a second editor gets a tab (browser_new_tab opens one in the
background and makes it that session's), what happens when it asks for a tab
another session is driving (refused by name, and marked
controlled_by_other_session in browser_tabs rather than hidden), the two
recording tool names, what browser_record_stop returns, and why the video
cannot come back through a tool reply.
New screenshots. The old pair was three weeks old, and the same file was
used twice — once as the hero image and again as the first cell of "See it
work". The replacements were captured by two MCP servers attached at the same
time: one driving the public playground, one driving the local E2E fixture in a
tab of its own. That makes them evidence of the multi-session behaviour rather
than two captures taken separately and presented as a pair.
Checked while writing, and left alone
The 49-tool count and the 12 tools in the read-only profile were taken from the
binary rather than from the previous README. CMake 3.25, Qt 6.5, Node 20.19,
Chrome 116 in the extension manifest, the three install roots, the environment
variable names, the extension id, the native-host name, "no TCP listener"
(the only listener is AF_UNIX), the 44/44 live-tool figure and the 35/35 display
matrix under both the Windows and macOS sections all check out against the code
and the recorded evidence.
One claim cannot be illustrated: the controlled tab sits in a pink AI CONTROL
tab group, and browser_screenshot captures the page viewport only, so no
screenshot can show browser chrome. It is verified live instead — a tab listing
reports "group": "AI CONTROL" for the session's tab.
Upgrading
browser_update_apply, then restart the MCP server in your client and reload the
extension at chrome://extensions.
Chrome Control MCP v1.5.2
Chrome Control MCP 1.5.2 fixes four faults that only a real browser could find.
1.5.1 repaired the identity check that stopped the bridge attaching at all. With
it attaching, the first things it tried were broken — and every one of them had
been green in the test suite the whole time.
browser_navigate never navigated
It answered, every time, that your URL was not http(s):
navigate requires an http(s) URL.
dispatchCommand builds each handler's arguments positionally:
const params = [session];
if (entry.tab) params.push(await activeTabId(session));
if (entry.args) params.push(args);handleNavigate was still declared (tabId, args). It received the session as
its tab id and the tab id as its arguments, read args.url off a number, found
undefined, and refused. Broken since 1.5.0, when the session was threaded
through the worker so that several editors could each have one.
The refactor that introduced it was made verifiable with ESLint: delete a
binding, and no-undef names every line that used it. That technique cannot see
this. handleNavigate never mentions session, so removing it from the
parameter list left nothing undefined — JavaScript simply passed the arguments
along one place to the left.
All 41 handlers the command table names directly were audited against the
dispatcher's contract. navigate was the only one. The check is now a test:
every entry's declared arity is compared with what the dispatcher will hand it,
with a guard so it cannot pass vacuously against an empty table.
Recording never recorded
browser_record_start failed on its first real start:
canvas.captureStream is not a function
The offscreen document built an OffscreenCanvas. captureStream() is defined
on HTMLCanvasElement and does not exist there. Having a DOM is the entire
reason that document exists — the service worker has none — and the code then
reached for the one canvas that is not part of it.
Nothing caught it because browser/extension/offscreen.js was the one shipped
file no test loaded. The recording suite stubs the document out and tests the
worker's side of the conversation, which is a reasonable seam and left the
encoder with no coverage at all. It has a suite of its own now, whose harness
gives the context an OffscreenCanvas with no captureStream, exactly as
Chrome does. Against the old code it reproduces the production error verbatim.
An orphaned relay held the bridge shut
Reloading the extension closes the relay's port. The relay spends nearly all its
life blocked reading the pipe, waiting for the server to send a command, and
from there Chrome going away is invisible: nothing wakes that read. The process
stayed alive holding its end of a pipe created with nMaxInstances = 1 — the
anti-squatting property that lets exactly one client connect — so every relay
Chrome started afterwards was refused:
Cannot open the bridge pipe (err=231)
231 is ERROR_PIPE_BUSY. The bridge stayed down until the orphan was killed by
hand, and an extension reload produced one every time.
The relay now watches its own standard input and ends the process when Chrome
lets go. Two properties matter. It peeks and never consumes, because the
pump reads that same descriptor on another thread and a check that took a byte
would steal part of a frame. And it is one-sided: only a definite hangup
counts, so a relay run from a console — where the check fails for an unrelated
reason — is not killed by a guess.
It arms at relay start rather than after the handshake. The pump is where an
unnoticed Chrome is fatal, but every earlier phase can strand a process too, and
there is no phase in which carrying on without a browser is the right answer.
Ending the process is the whole repair. The relay holds no state worth saving —
every id, ref and session lives in the MCP process — and Chrome starts a new one
on the next port connection.
Two editors could not share the browser
The feature 1.5.0 was built for worked only while the first session happened not
to hold the tab in front.
sessionWindow() answers which window a session is working in. Every caller
wants only the id: which window to list, to resolve an index against, or to open
a tab in. It got one by calling activeTab(), which adopts the user's active
tab as the session's and refuses when another session already holds it. So the
second editor was told:
That tab is being driven by another Chrome Control MCP session (server 26124).
Choose a different tab, or open one with browser_new_tab.
and browser_new_tab failed the same way, for the same reason. The advice and
the refusal were the same call.
Asking which window now reads the window directly and takes no tab from anyone.
What this says about the tests
Four faults, all shipped green. Three of them sat in places the suites were
structured not to reach: a file that was stubbed rather than loaded, a parameter
list that no reference could contradict, and a process lifetime that only a real
port closing can exercise. The fourth was a lease check doing its job in a place
that should never have asked it.
Each now has a test that fails against the code as it shipped, and the live
evidence is recorded in docs/VERIFICATION.md rather than summarised here.
Upgrading
browser_update_apply as usual, then restart the MCP server in your client and
reload the extension at chrome://extensions. Neither Chrome nor your editor
reloads code underneath a running process.
--install now reports whether it copied anything. It is a no-op when the
version is already installed and already current — which is right, because the
VS Code extension calls it on every activation — but it answered ok either
way, so a rebuilt tree of the same version looked installed when nothing had
moved.
Chrome Control MCP v1.5.1
Warning
Superseded by v1.5.2. Do not use this release.
1.5.1 fixed the identity check that stopped the bridge attaching, and that fix is
real. But with the bridge attaching, two things it reached for were broken, and both
are still broken here:
browser_navigatenever navigates. It refuses every URL with "navigate requires
an http(s) URL", including valid ones. Broken since 1.5.0.- Recording never works.
browser_record_startfails with "canvas.captureStream is
not a function". Broken since 1.5.0, when the feature was introduced.
1.5.2 also stops an orphaned relay from holding the bridge shut after an extension
reload, and lets a second editor open a tab while the first is driving one.
Chrome Control MCP 1.5.1 fixes a Windows bug that stopped the bridge attaching at all.
One file, two names
On a Windows managed install every relay refused every server:
The bridge is served by
...\versions\1.5.0\chrome_control_mcp.exe, but this relay is
...\current\chrome_control_mcp.exe. Chrome starts the executable named in its native-messaging
registration, so re-register from the server you are running.
The advice was sound for the fault it was written for -- two different copies of the program, one
serving MCP and another registered with Chrome -- but that was not this fault. Both processes were
the same file, started from the same path.
current is a directory junction onto versions/<version>, and the identity check asked two
different questions without noticing. A process asked about ITSELF with GetModuleFileNameW, which
reports the path it was launched through, and about its PEER with QueryFullProcessImageNameW,
which reports what that path resolves to. Through a junction those are two spellings of one file.
Each side compared them, found a stranger, and closed the connection.
The check is symmetric, so both directions failed: the relay refused the server before the
handshake, and the server would have refused the relay after it.
What else it broke
The same comparison answers a second question -- is the process that published this rendezvous
record still alive and still one of ours. With the comparison always false, every OTHER server's
record looked abandoned. Servers swept each other's records as stale and republished their own, so
the multi-session support released in 1.5.0 spent its time undoing itself. That is visible in a
record's timestamp moving without its owner having done anything.
The fix
The POSIX half had been right since the versioned layout landed, and said why:
// Canonicalize both sides: one of them can arrive through a symbolic link
// (an install reached through `current/`), and comparing the raw paths would
// then call our own process a stranger.
The Windows half never got that. It also existed twice -- once in the pipe server, once in the
relay -- in copies that could drift without anything failing to build. Both are now one module,
windows_process_identity, which asks about this process and about a peer through the same call
and canonicalizes the answer, so a junction, a short (8.3) path component or a difference in case
cannot make one file look like two.
Why no test caught it
Every existing test ran the binary from where it was built. The asymmetry only appears when the
process doing the asking was ITSELF launched through the junction, which no test arranged and no
build-tree run ever does. It became reachable the moment 1.5.0's self-update put both the MCP
command path and the native-host registration behind current.
test_windows_process_identity now builds a real junction, re-launches the test binary through it,
and has that child -- standing exactly where the relay stands -- decide whether the parent is the
same program. Against the pre-fix code that answer is "no" and the test fails, which is what makes
it worth having.
Its teardown removes the junction as a link, never recursively. The first version of the test used
QTemporaryDir, whose recursive cleanup followed the junction and deleted the contents of the build
directory it pointed at.
Upgrading
browser_update_apply as usual. Afterwards restart the MCP server in your client and press reload
on the extension at chrome://extensions: neither Chrome nor your editor reloads code underneath a
running process, and until the server restarts it keeps serving the old build.
Chrome Control MCP v1.5.0
Warning
Superseded. Do not use this release. Three of the things it advertises do not work.
The browser bridge cannot attach on a Windows managed install. That install is reached
through current, a directory junction onto versions/<version>. The bridge refuses any peer
not running its own binary, and it asked about itself and about its peer through two different
calls: one reports the path a process was launched through, the other what that path resolves
to. Through a junction those are two names for one file, so every relay refused every server:
The bridge is served by ...\versions\1.5.0\chrome_control_mcp.exe, but this relay is
...\current\chrome_control_mcp.exe.
Linux and macOS are unaffected. Fixed in v1.5.1.
browser_navigate never navigates. It refuses every URL, valid ones included, with
"navigate requires an http(s) URL". Fixed in v1.5.2.
Recording never works. browser_record_start fails with "canvas.captureStream is not a
function" on the first start, every time. Fixed in v1.5.2.
Multi-session, which this release introduced, also works against itself here: the same faulty
comparison decides whether another server's rendezvous record is live, so servers sweep each
other's records as abandoned.
Chrome Control MCP 1.5.0 lets more than one agent drive the browser, and adds recording.
More than one agent
Two editors open on two projects each got an MCP server, and only one of them got the browser. The
second stood down -- "Another Chrome Control MCP server already owns the browser bridge" -- and
served its native tools with browser control off until the first exited. That was deliberate, and it
was the single most common thing to run into.
The limit was not really the transport. It was that the extension's service worker kept one
session's state in module-level variables: the CDP attachment, the adopted tab, the DOM generation,
the snapshot tab and epoch, the screenshot transform, the tab and window listings, the dialog arm,
the command generation, the network-idle set, the emulation and auth overrides, the upload buffer.
Every one of those is a correctness guard, and two agents sharing them would not have failed loudly.
They would each have invalidated the other's view of the page and carried on: a tab index that named
something else by the time it was used, an element ref pointing into a document that had been
replaced.
Those twenty-six are now fields of a session record, and the worker holds a set of them -- one per
native port, one port per server. Each port's listeners close over their session, so an arriving
frame never has to be matched back to one by searching. Only the browser's own events need routing,
because they name a tab and nothing else: a debugger detach goes to whichever session holds that
attachment, a new tab to whichever adopted its opener.
Keeping them apart
A transport that served several clients without leases would have been the worse half of this
change. Two agents would appear to work while corrupting each other's view of the page, which is
harder to notice than a refusal.
So a tab is leased by the session that adopted it or has a debugger attachment on it, and the second
session to reach for it is refused by name -- browser_select_tab on a tab another session is
driving says so, rather than failing as though the tab were gone. Tabs held elsewhere are marked in
browser_tabs rather than hidden: the operator can see the whole window either way, and a listing
that gave no reason for the refusal would read as a bug.
Chrome already allows one debugger client per target, which covers everything that goes through the
DevTools protocol. The lease is what covers chrome.tabs, chrome.windows, cookies and downloads,
where Chrome would let two sessions collide without complaint.
How a relay finds its server
Rendezvous records are named for the process that published them -- browser_bridge-<pid>.json --
so several servers can advertise at once. The identity is in the file name rather than a
subdirectory because the POSIX socket lives in that same directory and its path has to fit
sockaddr_un; a nesting level would spend that budget for nothing.
The relay no longer computes a path. It lists what is published, offers the list to the extension,
and connects to the one the extension names. The relay speaks first, as it already did with
bridge_ready, because the extension has no filesystem access and so has no server to name until it
is offered one -- and because a frame sent unprompted would be read by an older relay as the reply to
a command it had not yet sent, putting every later exchange one step out of phase. Chrome does not
reload an unpacked extension when its files change, so that pairing is the normal case after an
update.
A server the extension asks for that is no longer published yields no connection rather than a
substitute: attaching a session to a server it did not choose is worse than reporting the bridge
unavailable.
Records left behind by servers that exited are swept when a server starts. A single shared record
never needed that -- it was simply overwritten by whoever published next.
Recording
browser_record_start records the session's tab to a .webm; browser_record_stop ends it and
returns where it was saved. Video only; there is no audio.
The video never comes back through a tool reply. The bridge is one command, one reply, with hard
caps on the reply -- 16 MiB for a screenshot, 8 MiB for text -- so a recording of any length could
not fit through it. Stop returns a path, the way browser_download does.
Beside the video it writes <name>.timeline.json: every command that ran while recording, with its
id, its name, its offset in milliseconds, and the DOM generation at that moment. That is the
machine-readable half, and it needs no second encoder -- it is what lets the frames be read against
what drove them.
Why not chrome.tabCapture
Tab capture is the obvious API for this and it cannot be used. Chrome's documentation is explicit:
capture "can only be called after the user invokes an extension, such as by clicking the extension's
action button", and "only tabs for which the extension has been granted the activeTab permission
can be used as the target tab". That grant comes from a user invoking the extension on that specific
tab. An agent-driven start has no such invocation, and <all_urls> does not substitute.
Frames come from the DevTools protocol instead -- Page.startScreencast, on the target this session
is already attached to, the same channel every click and snapshot already uses. It needs no gesture
and no permission this extension did not already hold. The cost is video only, and frame pacing that
is whatever CDP delivers rather than a true fixed rate.
The one permission recording adds is offscreen, for a hidden extension page to run the encoder in;
MediaRecorder needs a DOM and the service worker has none. It grants no access to the user's
browsing. debugger was already held and is strictly more powerful than tab capture would have
been. Chrome shows its own indicator while a tab is being debugged.
Chrome allows exactly one offscreen document per extension, so the recorder inside it is a map keyed
by session and every message to it names the session it is for. There is no "current recording" --
a value meaning that would be wrong the moment a second session started one.
What it refuses
Both are refusals rather than silent substitutions, because both alternatives lose work without
saying so:
- A second start for the same session is refused. Restarting would discard the first recording
with no way to tell it had happened. - Changing tabs while recording is refused. A recording follows one tab, and ending it because
the operator changed tabs would lose the video to an action they could have done in the other
order.browser_select_tab,browser_new_tabandbrowser_window new/focusrefuse;
browser_window closedoes not, because it does not retarget the session.
If the session ends while recording -- the bridge drops, the tab closes, DevTools detaches -- the
recording is discarded, not finalized. The frames stop arriving and the encoded bytes cover only
part of what was asked for, so writing them out would hand back a file that looks complete and is
not.
Fixed
-
A browser-initiated detach now releases what a deliberate one does.
detachAllcleared thirteen
things; theonDetachlistener cleared ten. The three it forgot were the three with no symptom at
the moment they went wrong: a polling handler kept driving a tab nothing was attached to until its
own timeout expired, a User-Agent captured from a page the session no longer drove could be
restored over the next one, and a half-delivered upload survived into the next session. -
Two installs running at once can no longer corrupt each other. An exclusive cross-process lock on
the install root spans each whole check-and-change; without it, two updaters shared one staging
directory name and could overwrite each other's partial download -- so the checksum that proved the
first could be checked against the second.
Changed
- The full profile advertises 49 tools; the read-only profile still advertises 12. Recording is
absent from read-only.
Verified
Windows 11 Pro 26200, MSVC 19.50, Qt 6.10.3, Node 24. Linux via WSL, GCC, Qt 6.11.2.
- 17/17 native and extension suites on Windows and Linux, including four suites written for this
release: the transport handshake, session teardown, multi-session leasing, and recording. - 110 JavaScript tests over the service worker, which is evaluated exactly as shipped.
- ASan+UBSan 17/17. clang-tidy and exhaustive cppcheck clean over the whole compile dat...
Chrome Control MCP v1.4.0
Warning
Superseded. On Windows this release cannot attach the browser bridge at all.
The managed install is reached through current, a directory junction onto
versions/<version>. The bridge refuses any peer that is not running its own binary, and it
asked about itself and about its peer through two different calls: one reports the path a
process was launched through, the other reports what that path resolves to. Through a junction
those are two names for one file, so every relay refused every server:
The bridge is served by ...\versions\1.4.0\chrome_control_mcp.exe, but this relay is
...\current\chrome_control_mcp.exe.
This is the first release with the versioned install layout, so it is the first one the fault
could reach. Linux and macOS are unaffected: that half of the check already canonicalized both
paths. Fixed in v1.5.1.
Chrome Control MCP 1.4.0 makes updating this server stop being a chore you do by hand, and makes
installing it something the VS Code Marketplace can do for you.
The update path
Until now, updating meant closing whatever was using the server, replacing files, and then
repairing what the new path broke. The files that had to be replaced were the ones being executed,
and Windows does not allow that. Worse, the install directory carried the version number, so every
upgrade invalidated three things at once: the MCP client's command, Chrome's unpacked-extension
folder, and the native-messaging registration. The last of those costs a trip through
chrome://extensions, which is the one step nothing can automate.
Nothing locked is written now. Each version is installed into <root>/versions/<version> and a
current link is repointed at it -- a directory junction on Windows, a symbolic link on Linux and
macOS. A junction rather than a symbolic link because Windows asks for elevation or Developer Mode
for the latter, and updating has to stay a user-level operation.
Everything outside this program names a path under current, so installing a new version moves
none of them.
Added
browser_update_statusreports where this build is installed, which versions are present, and
what the stable link points at. It touches no network.browser_update_checkasks GitHub which release is newest.browser_update_applyinstalls it. The running executable is never overwritten, so the update
cannot fail on a locked file, and the new build takes effect the next time the client starts the
server.--installputs a copy of this program into the managed layout, registers the native host, and
prints the command path and the Chrome extension folder as one line of JSON. It is how an
unpacked release archive becomes an install without first configuring an MCP client. Run on a
copy that is already installed, it repairs a registration another copy overwrote.--versionprints the build.
A copy running from a build tree or an unpacked download cannot update in place, because the file
that would have to be replaced is the one executing. The first browser_update_apply moves such a
copy into the managed layout and then installs the newest release on top of it. That move is the
only time the client's command path changes.
Install root is %LOCALAPPDATA%\Programs\ChromeControlMCP on Windows and sits under the per-user
data directory on Linux and macOS. CHROME_CONTROL_MCP_INSTALL_ROOT overrides it.
Integrity boundary
A download is checked against the SHA-256 published beside it in the same release, and a mismatch
is discarded rather than installed. That detects a truncated or corrupted transfer. It is not a
signature: the archive and the checksum come from one origin over one transport. This project still
ships no signing key and this release does not pretend otherwise.
VS Code Marketplace
Chrome Control MCP publishes as four platform-specific extensions -- win32-x64, linux-x64,
darwin-arm64, darwin-x64 -- each carrying the build for its platform, so installing needs no
second download and no path to configure.
The extension registers the server through the MCP server definition provider API rather than
writing an mcp.json, and points it at the stable link, so the registration stays correct after the
server updates itself. Marketplace updates and the server updating itself land in the same managed
install; whichever is newer is the one that runs.
The extension computes nothing about where the server lives. It runs --install and uses the path
that comes back, because a second copy of those rules in TypeScript would be free to drift from the
ones in the server without anything failing loudly.
Two commands: one repairs the install and its native-host registration, the other copies and opens
the folder Chrome has to be pointed at.
Fixed
browser_update_*would have been routed to the browser bridge, because everything named
browser_*was. A question about this program's own install would have answered "the extension
is not attached" -- exactly the state someone is in when they reach for it.- The path functions that decide whether a build is in a managed install used
QDir::cdUp, which
consults the filesystem and refuses a directory that does not exist. The answer to a question
about the shape of a path depended on whether that path happened to be mounted. - Unpacking names the system
tarexplicitly on Windows instead of taking the first one on PATH. A
POSIX tar earlier on PATH readsC:\...as a remote host and fails trying to reach a machine
calledC.
Changed
- Release archives carry the Qt Network runtime and the platform's Qt TLS backend, which the update
path needs to reach the release host. One backend per platform, not every one Qt built: the
others carry runtime dependencies the package does not include, and a plugin that cannot load is
a confusing failure rather than a fallback. The SPDX inventory is unchanged; both come from the
qtbase source already recorded. - The version number has one home. The server, the updater, and the packaged archive read the CMake
project version instead of keeping separate copies -- a stale copy inside an updater being the
one place that silently does the wrong thing. - Releases publish a
.vsixper platform beside the archives, andSHA256SUMS.txtcovers them. - The full profile advertises 47 tools; the read-only profile advertises 12.
browser_update_apply
is absent from the read-only profile by design: it replaces the program on disk, which is a
mutation whatever the browser profile says.
Verified
Windows 11 Pro 26200, MSVC 19.50, Qt 6.10.3, Node 24.13.0.
- 12/12 native and extension suites, including two new ones.
test_install_layoutcovers version
names that try to escape the versions directory, version-number ordering, and the link swap.
test_updatercovers asset naming, checksum parsing, version comparison, and installing a staged
release. - The link repoints while a server launched through it is running. The server keeps serving and
exits 0, and the version it was launched from is left whole. That is the claim this release
rests on, so it is tested twice: as a unit test holding a file open across the swap, and against
a real server process. - A real update over the network. A deliberately older build fetched the published release,
verified its SHA-256, unpacked it, and repointed the link:previous_version1.2.2,
installed_version1.3.1. - Pruning leaves a version whose files are still open untouched rather than half-deleting it under
the process running from it. - The packaged
win32-x64VSIX was unpacked and its bundled server run, including an HTTPS release
check through the bundled TLS backend.
The Linux and macOS suites and the live-Chrome runs predate this release; GitHub Actions builds and
tests all three platforms on every push, and a local Linux and macOS re-run of the two new suites
is still outstanding.
macOS 15.7.4, Intel, against the published macos-x64 archive of this release:
--versionand--installload both shipped frameworks and build the managed layout, with
currenta real symbolic link toversions/1.4.0.browser_update_checkreaches the release host over HTTPS through the bundled SecureTransport
backend and reads this version back. That was the part most likely to differ on macOS, where a
plugin has to resolve its own frameworks, and it is now observed rather than assumed.- The
darwin-x64VSIX packages from that same archive, and the binary inside the packaged
extension loads with no dyld error.
Linux has not had the same end-to-end run: it builds, tests, and packages, and its archive now
carries the Qt and ICU libraries it needs, but no Linux machine has fetched and installed a release
through browser_update_apply.