Skip to content

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.
  • Serverapps/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.)
  • Extensionapps/extension, loaded in each Chrome profile. Connects to 127.0.0.1:9222 on 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 in chrome.storage.local) in its hello; the server derives a slug from its label ("Work Laptop 💻"work-laptop; collisions get -2, -3) and browser_list_tabs prints it as Label — 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 snapshot

Most 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. The profileId announced in the hello is the routing key — nothing is ever rejected as "port in use".
  • Handshake: the extension's first frame is a hello request { protocol, profileId, label, slug?, extVersion, tabs }; the server replies hello_ack with the assignedLabel + assignedSlug the extension persists and re-announces (so slugs survive server restarts). profileId is a uuid the extension persists in chrome.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 to 127.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); after maxMissedPongs it 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 TargetQueueManager keyed "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 messaging Peer. 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 (a chrome.alarms heartbeat every ~24s, plus recreation on startup/install), builds the hello payload, watches chrome.tabs and pushes tabs_changed, and runs lib/executor.ts to perform each action by tab id.
  • Content script (entrypoints/content.ts + lib/dom.ts) does the in-page work: builds the ARIA snapshot (stamping data-mcp-ref on 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 dials 127.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 carries tabId). Server-only JSON-Schema conversion is isolated behind the ./json-schema subpath so the extension bundle never pulls in zod-to-json-schema.
  • packages/messaging — a transport-agnostic Peer: request/response with correlation ids + timeouts, notifications, and ping/pong. It has no ws, no chrome, no node — the caller injects a PeerSocket adapter (server wraps ws; extension wraps the browser WebSocket). 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.config and /mcp.config subpaths.
  • packages/utilswait, backoffDelay, compositeTabId.

9. Build & run

  • Server: dev with Bun (bun --watch); ship with tsup to an ESM Node bin (apps/server/tsup.config.ts inlines the @monkbrowse/* packages via noExternal, keeps npm deps external). npx monkbrowse works like v1.
  • Extension: WXT (Vite) builds the MV3 bundle; wxt build → unpacked dist/chrome-mv3, wxt zip → Web Store zip in dist-store/. The unpacked build pins manifest.key (appConfig.unpackedExtensionKey) so its id is constant and the server can allow-list it in advance; MONKBROWSE_STORE_BUILD=1 strips 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/messaging compile with no node/chrome/DOM types, so a leaked process/chrome/document fails 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) — runs lib/dom.ts against 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.ts drives execWire through 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 messaging Peer (linked in memory, no WebSocket) → real execWire → headless DOM. A browser_click tool call genuinely clicks a real element; browser_type fills 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.

Apache-2.0 licensed. Privacy