Architecture (v2)
One-stop guide to how Monkbrowse drives many tabs across many Chrome profiles from a single MCP server.
1. What this is
An MCP server (a Node/Bun CLI) plus an MV3 Chrome extension. Together they let an AI app automate the Chrome you already use — logged in, real fingerprint — instead of a fresh headless browser.
The defining capability: one server, many profiles, many tabs, concurrently. v1 (and every single-socket clone we surveyed) held exactly one WebSocket and dropped the previous connection on each new one — so only one tab in one profile could ever be driven. v2 replaces that single socket with a registry and adds explicit tab addressing.
Why the extension route (not CDP/--remote-debugging-port): it uses your real, logged-in profile with no relaunch, and Chrome's newer security rules don't let you point the debug port at your live default profile anyway.
2. The processes
┌───────────────┐ stdio (MCP) ┌──────────────────────────────────┐ WebSocket ┌───────────────────────────┐
│ AI app │◀─────────────▶│ MCP server (this) │◀────:9222───▶│ extension — profile "Work"│
│ Claude/Cursor │ │ ConnectionRegistry │◀────:9222───▶│ extension — profile "Home"│
└───────────────┘ │ Map<profileId, ProfileConnection>│ └───────────────────────────┘
│ one ws listener, all profiles │
└──────────────────────────────────┘- AI app — the MCP client; launches the server and speaks MCP over stdio.
- Server —
apps/server. Exposes browser tools to the AI, and binds one WebSocket listener (:9222) that every profile's extension connects to. Routes each tool call to the right profile connection and tab. (A second control port,:9219, coordinates multiple AI sessions — see plans/2026-07-31-shared-daemon-design.md.) - Extension —
apps/extension, loaded in each Chrome profile. Connects to127.0.0.1:9222on its own — no configuration — and performs the actions in real tabs.
stdout is the MCP transport, so all server logs go to stderr (apps/server/src/log.ts).
3. Addressing: slug = profile, number = tab
A tool call names two coordinates — a profile, and a tab inside it:
- Profile ⇒ a slug. Every extension announces a stable
profileId(a uuid persisted inchrome.storage.local) in its hello; the server derives a slug from its label ("Work Laptop 💻"→work-laptop; collisions get-2,-3) andbrowser_list_tabsprints it asLabel — profile "slug" (profileId-prefix). - Tab ⇒ a simple number (1, 2, 3…) the extension assigns each shared tab, shown in the popup. Only tabs the user shares (a toggle per tab) are numbered, visible to the AI, or drivable — the rest are invisible to the server.
Every interaction tool takes optional profile (a string) and tab (the number). profile resolves in order: exact profileId, exact slug (input is slugified first, so any casing/spacing of the label lands), unique slug prefix, unique profileId prefix; ambiguity throws an error listing the candidates. Omit profile → the focused profile (the only connected one, else the most-recently-used). Omit tab → the profile's active tab. The server maps (profile, tab) → the real chrome tab id before sending on the wire. New tools browser_list_tabs (aggregates every profile, numbered) and browser_switch_tab round it out.
The tab numbers (slots) are stable per profile — persisted in the extension so a number keeps pointing at the same tab across service-worker restarts. That's what lets a user say "tab 2" out loud and have it mean the same tab to the AI.
4. Request lifecycle
Trace browser_click { profile: "home", tab: 2, ref, element }:
AI ──MCP CallTool──▶ apps/server/src/mcp.ts
validate args with the tool's zod schema (packages/protocol)
│
▼ apps/server/src/tools/index.ts
registry.resolveProfile("home") → ProfileConnection
registry.tabIdForSlot(conn, 2) → chrome tabId 5417 (refresh on miss)
queue key "<profileId>:5417" → serialize same-tab calls
│
▼ apps/server/src/registry.ts (send)
conn.peer.request("browser_click", { ref, element, tabId }, {timeout})
│ messaging envelope over the WebSocket
▼ extension: offscreen doc receives the request frame
relays to the service worker (chrome.runtime message)
▼ apps/extension/lib/executor.ts
resolve tab → content script → clickRef(ref) on [data-mcp-ref=ref]
▲ result { tabId }
server chains captureAriaSnapshot(tabId) → one browser_snapshot round-trip
▲
AI ◀── "Clicked ..." + fresh ARIA snapshotMost action tools return a fresh ARIA snapshot of the resolved tab, so the AI sees the new state without a screenshot. The snapshot is one round-trip returning url+title+yaml together (v1 chained three calls that could straddle a tab change).
5. The connection registry (the fix)
apps/server/src/registry.ts. ConnectionRegistry owns Map<profileId, ProfileConnection> plus a slug → profileId index. Per profile:
ProfileConnection = { profileId, peer|null, label, slug, status, connectedAt, tabs: Map<tabId,TabInfo> }- One
ws.WebSocketServer(:9222,apps/server/src/ws-server.ts) accepts every profile. TheprofileIdannounced in the hello is the routing key — nothing is ever rejected as "port in use". - Handshake: the extension's first frame is a
hellorequest{ protocol, profileId, label, slug?, extVersion, tabs }; the server replieshello_ackwith theassignedLabel+assignedSlugthe extension persists and re-announces (so slugs survive server restarts).profileIdis a uuid the extension persists inchrome.storage.local— it's the reconnect key, stable across service-worker restarts. - Replace-same-profile-only: a reconnect with the same profileId replaces its own socket (keeps label + tabs); different profileIds coexist on the one port. Hard cap: 32 profiles — new connections at the cap are rejected, never evict a live profile, but a same-profileId reconnect still works. This is the exact opposite of v1's "close the previous socket."
- Origin gate: only allow-listed exact
chrome-extension://<id>Origins may connect — two by default (the Web Store id, and the pinned id every unpacked build of this repo gets), plus anything named with--allow-extension <id...>. Missing or web-page Origins are dropped before the handshake. This is the load-bearing check: a WebSocket has no CORS preflight, so any page you visit can open one to127.0.0.1:9222, and only the server can refuse it — without this gate a drive-by site would drive your logged-in browser. Matching exact ids on top of that is the weaker half: it limits escalation by another installed extension, which is why pinning a known dev id (rather than dropping the gate) was the right way to remove the setup friction. Pending (pre-hello) sockets are capped at 16 with a 10s hello timeout. - Disconnect keeps the record (
peer=null,status='disconnected') so a suspended service worker's slot survives until it reconnects. - Liveness: the server pings each peer (
packages/messaging); aftermaxMissedPongsit drops the socket.
6. Concurrency
apps/server/src/queue.ts. Requests are correlated by id over each socket, so:
- Across profiles: free parallelism — different profiles are different sockets, different pending maps.
- Within a profile, different tabs: run in parallel.
- Same tab, mutating calls: serialized by a
TargetQueueManagerkeyed"profileId:tabId", so a type-then-click never interleaves.
7. Inside the extension (MV3)
apps/extension. The hard MV3 problem: a service worker suspends after ~30s idle, killing any socket it owns. Solution, split across three contexts:
- Offscreen document (
entrypoints/offscreen/main.ts) owns the WebSocket and the messagingPeer. Offscreen docs aren't subject to SW idle-suspension, so the socket survives. It relays each incoming request to the service worker and returns the result. - Service worker (
entrypoints/background.ts) is the Chrome-API executor. It creates/keeps the offscreen doc alive (achrome.alarmsheartbeat every ~24s, plus recreation on startup/install), builds thehellopayload, watcheschrome.tabsand pushestabs_changed, and runslib/executor.tsto perform each action by tab id. - Content script (
entrypoints/content.ts+lib/dom.ts) does the in-page work: builds the ARIA snapshot (stampingdata-mcp-refon elements), resolves those refs for click/type/hover/select, and buffers console logs. The SW injects it on demand for tabs opened before the extension loaded. - Popup + options (
entrypoints/popup,entrypoints/options) show connection status and let the user set this profile's optional name (its label — the slug is derived from it). There is nothing else to configure; the socket always dials127.0.0.1:9222.
Navigation, screenshots, tab list/switch run directly in the SW; DOM ops go to the content script.
8. Shared packages
packages/protocol— the single source of truth for both contracts, in zod: the AI-facing tool schemas (tools.ts) and the server↔extension wire messages (messages.ts, every tab-scoped payload carriestabId). Server-only JSON-Schema conversion is isolated behind the./json-schemasubpath so the extension bundle never pulls inzod-to-json-schema.packages/messaging— a transport-agnosticPeer: request/response with correlation ids + timeouts, notifications, and ping/pong. It has nows, nochrome, nonode— the caller injects aPeerSocketadapter (server wrapsws; extension wraps the browserWebSocket). This is what lets the same request/response logic run on both sides.packages/config— ports (extensionPort: 9222,controlPort: 9219) + names + error strings, with the/app.configand/mcp.configsubpaths.packages/utils—wait,backoffDelay,compositeTabId.
9. Build & run
- Server: dev with Bun (
bun --watch); ship with tsup to an ESM Node bin (apps/server/tsup.config.tsinlines the@monkbrowse/*packages vianoExternal, keeps npm deps external).npx monkbrowseworks like v1. - Extension: WXT (Vite) builds the MV3 bundle;
wxt build→ unpackeddist/chrome-mv3,wxt zip→ Web Store zip indist-store/. The unpacked build pinsmanifest.key(appConfig.unpackedExtensionKey) so its id is constant and the server can allow-list it in advance;MONKBROWSE_STORE_BUILD=1strips the key for the store zip, which must carry the store's own signing identity. The two output directories are separate so packaging never leaves the local unpacked build unkeyed. - Isomorphism guard:
packages/protocol+packages/messagingcompile with no node/chrome/DOM types, so a leakedprocess/chrome/documentfails typecheck rather than breaking the service worker at runtime.
10. Testing without Chrome
The suite simulates the browser so real Chrome is the final check, not the only one:
- DOM engine (
apps/extension/test/dom.test.ts) — runslib/dom.tsagainst a headless DOM (happy-dom): shadow-DOM piercing + click, same-origin iframe descent, drag, file upload, hidden-element skipping,evaluate, key combos. - Fake
chrome(test/helpers/fake-chrome.ts) — in-memory tabs/storage/scripting, with tab messages routed to the real content-op dispatch.executor.test.tsdrivesexecWirethrough it: navigate, new-tab auto-share, close, background-tab screenshot activation, the shared-tab guard, and real snapshot→click. - Full loop (
full-loop.test.ts) — the real MCP tool handler → real registry → real messagingPeer(linked in memory, no WebSocket) → realexecWire→ headless DOM. Abrowser_clicktool call genuinely clicks a real element;browser_typefills a real input. - Server (
apps/server/test) — registry (adopt / reconnect / no-eviction / slug resolution), tab resolution, and an integration test with multiple simulated profiles sharing one port over real WebSockets.
What still needs real Chrome: cross-origin iframes, MV3 service-worker suspension/offscreen survival, content-script injection into pre-open tabs, and layout-dependent visibility on complex pages.