This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

tools.browser-mcp: init

MCP server that controls the everyday Firefox through a WebExtension and
a native messaging host instead of Marionette/WebDriver, so
navigator.webdriver stays false and Cloudflare/reCAPTCHA stop flagging
normal browsing. No browser restart is needed to attach, tabs are
addressed by id on demand.

barnacle installs the server and links the native messaging host
manifest into /Library/Application Support/Mozilla/NativeMessagingHosts.
The extension is signed as unlisted on AMO via browser-mcp-sign.

+927
+8
machines/barnacle/configuration.nix
··· 28 28 29 29 self.packages.${pkgs.system}.secretive 30 30 pkgs.omp 31 + self.packages.${pkgs.system}.browser-mcp 31 32 32 33 # zsh dependencies 33 34 pkgs.fzf ··· 191 192 192 193 system.activationScripts.postActivation.text = '' 193 194 install ${./US_INTL.keylayout} /Library/Keyboard\ Layouts/US_INTL.keylayout 195 + # Native messaging host for the Browser MCP Firefox extension (system-wide 196 + # location, Firefox reads it for every user). 197 + mkdir -p "/Library/Application Support/Mozilla/NativeMessagingHosts" 198 + ln -sfn ${ 199 + self.packages.${pkgs.system}.browser-mcp 200 + }/lib/mozilla/native-messaging-hosts/browser_mcp.json \ 201 + "/Library/Application Support/Mozilla/NativeMessagingHosts/browser_mcp.json" 194 202 ''; 195 203 }
+355
tools/browser-mcp/browser_mcp.py
··· 1 + #!/usr/bin/env python3 2 + """Control the user's everyday Firefox from MCP clients without WebDriver. 3 + 4 + Two roles: 5 + 6 + host Native messaging host, spawned by Firefox when the Browser MCP 7 + extension connects. Relays newline-delimited JSON requests from a 8 + unix socket to the extension and routes replies back. Any number of 9 + clients can talk to it at once. 10 + serve stdio MCP server. Connects to the host's socket per tool call, so it 11 + keeps working across Firefox restarts without restarting itself. 12 + 13 + Neither role touches Marionette or remote debugging, so the browser never 14 + reports navigator.webdriver = true and captcha providers see a normal browser. 15 + """ 16 + 17 + from __future__ import annotations 18 + 19 + import asyncio 20 + import base64 21 + import contextlib 22 + import itertools 23 + import json 24 + import os 25 + import select 26 + import socket 27 + import struct 28 + import sys 29 + from pathlib import Path 30 + from typing import Any 31 + 32 + from mcp.server.fastmcp import FastMCP, Image 33 + 34 + PROTOCOL_LIMIT = 64 * 1024 * 1024 # screenshots can be several MB 35 + 36 + 37 + def socket_path() -> Path: 38 + """Socket shared by host and server; both run as the same user.""" 39 + if override := os.environ.get("BROWSER_MCP_SOCKET"): 40 + return Path(override) 41 + if runtime := os.environ.get("XDG_RUNTIME_DIR"): 42 + return Path(runtime) / "browser-mcp.sock" 43 + return Path.home() / ".cache" / "browser-mcp" / "browser-mcp.sock" 44 + 45 + 46 + # ── host ──────────────────────────────────────────────────────────────── 47 + 48 + 49 + def read_exact(fd: int, n: int) -> bytes | None: 50 + buf = b"" 51 + while len(buf) < n: 52 + chunk = os.read(fd, n - len(buf)) 53 + if not chunk: 54 + return None 55 + buf += chunk 56 + return buf 57 + 58 + 59 + def read_native(fd: int) -> dict[str, Any] | None: 60 + header = read_exact(fd, 4) 61 + if header is None: 62 + return None 63 + (length,) = struct.unpack("=I", header) 64 + body = read_exact(fd, length) 65 + if body is None: 66 + return None 67 + msg: dict[str, Any] = json.loads(body) 68 + return msg 69 + 70 + 71 + def write_native(msg: dict[str, Any]) -> None: 72 + data = json.dumps(msg).encode() 73 + sys.stdout.buffer.write(struct.pack("=I", len(data)) + data) 74 + sys.stdout.buffer.flush() 75 + 76 + 77 + def bind_socket(path: Path) -> socket.socket: 78 + """Bind the relay socket, taking it over from a previous host. 79 + 80 + The latest Firefox to start (e.g. after a restart while the old host 81 + lingers) owns the socket. 82 + """ 83 + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) 84 + with contextlib.suppress(FileNotFoundError): 85 + path.unlink() 86 + old_umask = os.umask(0o177) 87 + try: 88 + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 89 + listener.bind(str(path)) 90 + finally: 91 + os.umask(old_umask) 92 + listener.listen() 93 + listener.setblocking(False) 94 + return listener 95 + 96 + 97 + class Client: 98 + def __init__(self, conn: socket.socket) -> None: 99 + self.conn = conn 100 + self.buf = b"" 101 + 102 + 103 + def run_host() -> None: 104 + path = socket_path() 105 + listener = bind_socket(path) 106 + inode = path.stat().st_ino 107 + stdin_fd = sys.stdin.buffer.fileno() 108 + clients: dict[int, Client] = {} 109 + # extension request id -> (client fd, client's own request id) 110 + pending: dict[int, tuple[int, Any]] = {} 111 + ids = itertools.count(1) 112 + 113 + def drop(fd: int) -> None: 114 + client = clients.pop(fd) 115 + client.conn.close() 116 + for rid in [r for r, (cfd, _) in pending.items() if cfd == fd]: 117 + del pending[rid] 118 + 119 + def reply(fd: int, msg: dict[str, Any]) -> None: 120 + client = clients.get(fd) 121 + if client is None: 122 + return 123 + client.conn.setblocking(True) 124 + try: 125 + client.conn.sendall(json.dumps(msg).encode() + b"\n") 126 + except OSError: 127 + drop(fd) 128 + else: 129 + client.conn.setblocking(False) 130 + 131 + try: 132 + while True: 133 + readable, _, _ = select.select( 134 + [stdin_fd, listener.fileno(), *clients], [], [] 135 + ) 136 + for fd in readable: 137 + if fd == stdin_fd: 138 + msg = read_native(stdin_fd) 139 + if msg is None: 140 + return # Firefox closed the port 141 + target = pending.pop(msg.get("id", -1), None) 142 + if target is not None: 143 + cfd, cid = target 144 + reply(cfd, {**msg, "id": cid}) 145 + elif fd == listener.fileno(): 146 + conn, _ = listener.accept() 147 + conn.setblocking(False) 148 + clients[conn.fileno()] = Client(conn) 149 + else: 150 + client = clients[fd] 151 + try: 152 + chunk = client.conn.recv(65536) 153 + except BlockingIOError: 154 + continue 155 + except OSError: 156 + chunk = b"" 157 + if not chunk: 158 + drop(fd) 159 + continue 160 + client.buf += chunk 161 + while b"\n" in client.buf: 162 + line, client.buf = client.buf.split(b"\n", 1) 163 + try: 164 + req = json.loads(line) 165 + except json.JSONDecodeError as e: 166 + reply(fd, {"id": None, "error": f"bad request: {e}"}) 167 + continue 168 + rid = next(ids) 169 + pending[rid] = (fd, req.get("id")) 170 + write_native( 171 + { 172 + "id": rid, 173 + "method": req.get("method"), 174 + "params": req.get("params", {}), 175 + } 176 + ) 177 + finally: 178 + listener.close() 179 + # Only remove the socket if a newer host has not taken it over. 180 + with contextlib.suppress(FileNotFoundError): 181 + if path.stat().st_ino == inode: 182 + path.unlink() 183 + 184 + 185 + # ── MCP server ────────────────────────────────────────────────────────── 186 + 187 + 188 + class BrowserError(Exception): 189 + pass 190 + 191 + 192 + async def call(method: str, params: dict[str, Any] | None = None) -> Any: 193 + try: 194 + reader, writer = await asyncio.open_unix_connection( 195 + str(socket_path()), limit=PROTOCOL_LIMIT 196 + ) 197 + except (FileNotFoundError, ConnectionRefusedError) as e: 198 + raise BrowserError( 199 + "Firefox is not connected: the Browser MCP extension is not " 200 + "installed/enabled or Firefox is not running" 201 + ) from e 202 + try: 203 + req = {"id": 1, "method": method, "params": params or {}} 204 + writer.write(json.dumps(req).encode() + b"\n") 205 + await writer.drain() 206 + line = await reader.readline() 207 + finally: 208 + writer.close() 209 + if not line: 210 + raise BrowserError("Firefox disconnected before replying") 211 + resp: dict[str, Any] = json.loads(line) 212 + if "error" in resp: 213 + raise BrowserError(resp["error"]) 214 + return resp.get("result") 215 + 216 + 217 + def as_text(value: Any) -> str: 218 + return value if isinstance(value, str) else json.dumps(value, indent=1) 219 + 220 + 221 + def run_server() -> None: 222 + mcp = FastMCP( 223 + "browser", 224 + instructions=( 225 + "Controls the user's own running Firefox through an extension " 226 + "(no WebDriver, so sites don't flag it as automated). Tabs are " 227 + "addressed by tab_id from list_tabs/open_tab. Open new tabs in " 228 + "the background unless the user should see them. For " 229 + "interaction, take a snapshot to get element refs, then click, " 230 + "fill or press_key by ref. Refs are invalid after the page " 231 + "navigates. Events are synthetic: captchas and other checks that " 232 + "require a real user gesture must be solved by the user." 233 + ), 234 + ) 235 + 236 + @mcp.tool() 237 + async def list_tabs() -> str: 238 + """List all open tabs: id, windowId, active, status, title, url.""" 239 + return as_text(await call("list_tabs")) 240 + 241 + @mcp.tool() 242 + async def open_tab(url: str, active: bool = False, timeout_ms: int = 15000) -> str: 243 + """Open url in a new tab (in the background unless active) and wait for it to load.""" 244 + return as_text( 245 + await call( 246 + "open_tab", {"url": url, "active": active, "timeout_ms": timeout_ms} 247 + ) 248 + ) 249 + 250 + @mcp.tool() 251 + async def navigate(tab_id: int, url: str, timeout_ms: int = 15000) -> str: 252 + """Load url in an existing tab and wait for it to load.""" 253 + return as_text( 254 + await call( 255 + "navigate", {"tab_id": tab_id, "url": url, "timeout_ms": timeout_ms} 256 + ) 257 + ) 258 + 259 + @mcp.tool() 260 + async def navigate_history( 261 + tab_id: int, direction: str, timeout_ms: int = 15000 262 + ) -> str: 263 + """Go 'back' or 'forward' in a tab's history and wait for the load.""" 264 + return as_text( 265 + await call( 266 + "history", 267 + {"tab_id": tab_id, "direction": direction, "timeout_ms": timeout_ms}, 268 + ) 269 + ) 270 + 271 + @mcp.tool() 272 + async def reload(tab_id: int, timeout_ms: int = 15000) -> str: 273 + """Reload a tab and wait for it to load.""" 274 + return as_text( 275 + await call("reload", {"tab_id": tab_id, "timeout_ms": timeout_ms}) 276 + ) 277 + 278 + @mcp.tool() 279 + async def activate_tab(tab_id: int) -> str: 280 + """Bring a tab to the front and focus its window.""" 281 + return as_text(await call("activate_tab", {"tab_id": tab_id})) 282 + 283 + @mcp.tool() 284 + async def close_tab(tab_id: int) -> str: 285 + """Close a tab.""" 286 + return as_text(await call("close_tab", {"tab_id": tab_id})) 287 + 288 + @mcp.tool() 289 + async def snapshot(tab_id: int, max_elements: int = 300) -> str: 290 + """List the visible interactive elements and headings of a tab with refs. 291 + 292 + Lines look like `[12] button "Sign in"`; pass the number as ref to 293 + click, fill or press_key. Covers open shadow roots, not iframes. 294 + """ 295 + return as_text( 296 + await call("snapshot", {"tab_id": tab_id, "max_elements": max_elements}) 297 + ) 298 + 299 + @mcp.tool() 300 + async def get_text(tab_id: int, max_length: int = 20000) -> str: 301 + """Visible text of a tab (document.body.innerText), truncated to max_length.""" 302 + return as_text(await call("text", {"tab_id": tab_id, "max_length": max_length})) 303 + 304 + @mcp.tool() 305 + async def click(tab_id: int, ref: int) -> str: 306 + """Click an element from the latest snapshot.""" 307 + return as_text(await call("click", {"tab_id": tab_id, "ref": ref})) 308 + 309 + @mcp.tool() 310 + async def fill(tab_id: int, ref: int, value: str) -> str: 311 + """Replace the value of an input/textarea/contenteditable, pick a select 312 + option (by value or text), or set a checkbox/radio ("true"/"false").""" 313 + return as_text( 314 + await call("fill", {"tab_id": tab_id, "ref": ref, "value": value}) 315 + ) 316 + 317 + @mcp.tool() 318 + async def press_key(tab_id: int, key: str, ref: int | None = None) -> str: 319 + """Dispatch a key (DOM key name, e.g. "Enter", "Escape", "a") to ref or 320 + the focused element. Enter in a form input submits the form.""" 321 + return as_text( 322 + await call("press_key", {"tab_id": tab_id, "key": key, "ref": ref}) 323 + ) 324 + 325 + @mcp.tool() 326 + async def evaluate(tab_id: int, code: str) -> str: 327 + """Run JavaScript in a tab and return the JSON of its result. 328 + 329 + code is the body of an async function: use `return`. It runs in the 330 + extension's isolated content-script world with full DOM access; 331 + page-defined globals are reachable via window.wrappedJSObject. 332 + """ 333 + return as_text(await call("evaluate", {"tab_id": tab_id, "code": code})) 334 + 335 + @mcp.tool() 336 + async def screenshot(tab_id: int) -> Image: 337 + """PNG of a tab's visible viewport; works for background tabs too.""" 338 + data_url = await call("screenshot", {"tab_id": tab_id}) 339 + _, _, b64 = str(data_url).partition(",") 340 + return Image(data=base64.b64decode(b64), format="png") 341 + 342 + mcp.run() 343 + 344 + 345 + def main() -> None: 346 + # Firefox starts the host with the manifest path and extension id as 347 + # arguments; the MCP server is started without arguments. 348 + if len(sys.argv) > 1 and sys.argv[1] == "host": 349 + run_host() 350 + else: 351 + run_server() 352 + 353 + 354 + if __name__ == "__main__": 355 + main()
+97
tools/browser-mcp/flake-module.nix
··· 1 + { ... }: 2 + { 3 + perSystem = 4 + { 5 + pkgs, 6 + lib, 7 + self', 8 + ... 9 + }: 10 + let 11 + python = pkgs.python3.withPackages (ps: [ ps.mcp ]); 12 + extensionId = "browser-mcp@lassul.us"; 13 + pinentry = 14 + if pkgs.stdenv.hostPlatform.isDarwin then 15 + "${pkgs.pinentry_mac}/${pkgs.pinentry_mac.binaryPath}" 16 + else 17 + "${self'.packages.pinentry-rofi}/bin/pinentry-rofi"; 18 + in 19 + { 20 + # stdio MCP server (bin/browser-mcp) plus the native messaging host 21 + # Firefox spawns for the extension (bin/browser-mcp-host, manifest in 22 + # lib/mozilla/native-messaging-hosts). 23 + packages.browser-mcp = pkgs.stdenv.mkDerivation { 24 + pname = "browser-mcp"; 25 + version = (lib.importJSON ./extension/manifest.json).version; 26 + src = ./.; 27 + nativeBuildInputs = [ 28 + pkgs.makeWrapper 29 + pkgs.ruff 30 + python.pkgs.mypy 31 + ]; 32 + doCheck = true; 33 + checkPhase = '' 34 + runHook preCheck 35 + ruff check browser_mcp.py 36 + ruff format --check browser_mcp.py 37 + ${python}/bin/python -m mypy --strict browser_mcp.py 38 + runHook postCheck 39 + ''; 40 + installPhase = '' 41 + runHook preInstall 42 + install -Dm755 browser_mcp.py $out/libexec/browser_mcp.py 43 + makeWrapper ${python}/bin/python $out/bin/browser-mcp \ 44 + --add-flags $out/libexec/browser_mcp.py 45 + makeWrapper ${python}/bin/python $out/bin/browser-mcp-host \ 46 + --add-flags "$out/libexec/browser_mcp.py host" 47 + 48 + mkdir -p $out/lib/mozilla/native-messaging-hosts 49 + cat > $out/lib/mozilla/native-messaging-hosts/browser_mcp.json <<EOF 50 + { 51 + "name": "browser_mcp", 52 + "description": "Relay between the Browser MCP extension and browser-mcp", 53 + "path": "$out/bin/browser-mcp-host", 54 + "type": "stdio", 55 + "allowed_extensions": ["${extensionId}"] 56 + } 57 + EOF 58 + runHook postInstall 59 + ''; 60 + meta.mainProgram = "browser-mcp"; 61 + }; 62 + 63 + # Signs the extension as unlisted on addons.mozilla.org so release 64 + # Firefox installs it. Bump the version in extension/manifest.json 65 + # first: AMO signs every version only once. Credentials come from the 66 + # jwt_issuer/jwt_secret fields of the rbw entry accounts.firefox.com. 67 + packages.browser-mcp-sign = pkgs.writeShellApplication { 68 + name = "browser-mcp-sign"; 69 + runtimeInputs = [ 70 + pkgs.web-ext 71 + pkgs.rbw 72 + ]; 73 + text = '' 74 + out=''${1:-.} 75 + if [ -z "''${WEB_EXT_API_KEY:-}" ] || [ -z "''${WEB_EXT_API_SECRET:-}" ]; then 76 + if ! rbw unlocked 2>/dev/null; then 77 + rbw config set pinentry ${pinentry} 78 + rbw unlock 79 + fi 80 + WEB_EXT_API_KEY=$(rbw get --field jwt_issuer accounts.firefox.com) 81 + WEB_EXT_API_SECRET=$(rbw get --field jwt_secret accounts.firefox.com) 82 + export WEB_EXT_API_KEY WEB_EXT_API_SECRET 83 + fi 84 + # web-ext writes .amo-upload-uuid into the source dir, so sign a 85 + # writable copy of the extension. 86 + src=$(mktemp -d) 87 + trap 'rm -rf "$src"' EXIT 88 + cp -r ${./extension}/. "$src" 89 + chmod -R u+w "$src" 90 + web-ext sign \ 91 + --channel unlisted \ 92 + --source-dir "$src" \ 93 + --artifacts-dir "$out" 94 + ''; 95 + }; 96 + }; 97 + }
+195
tools/browser-mcp/extension/background.js
··· 1 + /* Browser MCP - Firefox side of the browser-mcp bridge. 2 + * 3 + * Connects to the browser_mcp native messaging host, which relays requests 4 + * from MCP servers over a unix socket. Everything runs through the regular 5 + * WebExtension APIs, so the browser never enters WebDriver/Marionette mode: 6 + * navigator.webdriver stays false and captcha providers see a normal browser. 7 + * 8 + * Request: {id, method, params} 9 + * Response: {id, result} | {id, error} 10 + */ 11 + 12 + const HOST = "browser_mcp"; 13 + const DEFAULT_LOAD_TIMEOUT_MS = 15000; 14 + 15 + let port = null; 16 + let retryDelay = 1000; 17 + 18 + function connect() { 19 + port = browser.runtime.connectNative(HOST); 20 + port.onMessage.addListener(onRequest); 21 + port.onDisconnect.addListener((p) => { 22 + console.warn("browser-mcp host disconnected:", p.error); 23 + port = null; 24 + setTimeout(connect, retryDelay); 25 + retryDelay = Math.min(retryDelay * 2, 60000); 26 + }); 27 + } 28 + 29 + async function onRequest(msg) { 30 + // Any message means the host is up; reset backoff for the next reconnect. 31 + retryDelay = 1000; 32 + let reply; 33 + try { 34 + const handler = handlers[msg.method]; 35 + if (!handler) throw new Error(`unknown method: ${msg.method}`); 36 + reply = { id: msg.id, result: await handler(msg.params || {}) }; 37 + } catch (e) { 38 + reply = { id: msg.id, error: String(e && e.message ? e.message : e) }; 39 + } 40 + if (port) port.postMessage(reply); 41 + } 42 + 43 + function tabInfo(t) { 44 + return { 45 + id: t.id, 46 + windowId: t.windowId, 47 + active: t.active, 48 + status: t.status, 49 + title: t.title, 50 + url: t.url, 51 + }; 52 + } 53 + 54 + // Runs `start` (which returns the tab id) and resolves once that tab reports 55 + // status "complete", or after the timeout (the page keeps loading; the 56 + // caller gets loaded: false). The listener goes up before `start` so a fast 57 + // load can't complete unseen. 58 + function navigateAndWait(start, timeoutMs) { 59 + return new Promise((resolve, reject) => { 60 + const completed = new Set(); 61 + let tabId = null; 62 + let timer = null; 63 + const cleanup = () => { 64 + clearTimeout(timer); 65 + browser.tabs.onUpdated.removeListener(listener); 66 + }; 67 + const finish = async (loaded) => { 68 + cleanup(); 69 + try { 70 + resolve({ loaded, tab: tabInfo(await browser.tabs.get(tabId)) }); 71 + } catch (e) { 72 + reject(e); 73 + } 74 + }; 75 + const listener = (id, change) => { 76 + if (change.status !== "complete") return; 77 + if (tabId === null) completed.add(id); 78 + else if (id === tabId) finish(true); 79 + }; 80 + browser.tabs.onUpdated.addListener(listener); 81 + start().then( 82 + (id) => { 83 + tabId = id; 84 + if (completed.has(id)) finish(true); 85 + else timer = setTimeout(() => finish(false), timeoutMs); 86 + }, 87 + (e) => { 88 + cleanup(); 89 + reject(e); 90 + }, 91 + ); 92 + }); 93 + } 94 + 95 + // Content-side helpers live in the extension's isolated world of the page 96 + // (page.js), invisible to the page's own scripts. 97 + async function pageCall(tabId, op, args) { 98 + const [present] = await browser.tabs.executeScript(tabId, { 99 + code: "typeof __browserMcp !== 'undefined'", 100 + }); 101 + if (!present) await browser.tabs.executeScript(tabId, { file: "/page.js" }); 102 + const [result] = await browser.tabs.executeScript(tabId, { 103 + code: `__browserMcp.${op}(${JSON.stringify(args)})`, 104 + }); 105 + return result; 106 + } 107 + 108 + const handlers = { 109 + async list_tabs() { 110 + return (await browser.tabs.query({})).map(tabInfo); 111 + }, 112 + 113 + async open_tab({ 114 + url, 115 + active = false, 116 + timeout_ms = DEFAULT_LOAD_TIMEOUT_MS, 117 + }) { 118 + return navigateAndWait( 119 + async () => (await browser.tabs.create({ url, active })).id, 120 + timeout_ms, 121 + ); 122 + }, 123 + 124 + async navigate({ tab_id, url, timeout_ms = DEFAULT_LOAD_TIMEOUT_MS }) { 125 + return navigateAndWait(async () => { 126 + await browser.tabs.update(tab_id, { url }); 127 + return tab_id; 128 + }, timeout_ms); 129 + }, 130 + 131 + async history({ tab_id, direction, timeout_ms = DEFAULT_LOAD_TIMEOUT_MS }) { 132 + if (direction !== "back" && direction !== "forward") { 133 + throw new Error("direction must be 'back' or 'forward'"); 134 + } 135 + return navigateAndWait(async () => { 136 + if (direction === "back") await browser.tabs.goBack(tab_id); 137 + else await browser.tabs.goForward(tab_id); 138 + return tab_id; 139 + }, timeout_ms); 140 + }, 141 + 142 + async reload({ tab_id, timeout_ms = DEFAULT_LOAD_TIMEOUT_MS }) { 143 + return navigateAndWait(async () => { 144 + await browser.tabs.reload(tab_id); 145 + return tab_id; 146 + }, timeout_ms); 147 + }, 148 + 149 + async activate_tab({ tab_id }) { 150 + const tab = await browser.tabs.update(tab_id, { active: true }); 151 + await browser.windows.update(tab.windowId, { focused: true }); 152 + return tabInfo(tab); 153 + }, 154 + 155 + async close_tab({ tab_id }) { 156 + await browser.tabs.remove(tab_id); 157 + return { closed: tab_id }; 158 + }, 159 + 160 + async snapshot({ tab_id, max_elements = 300 }) { 161 + return pageCall(tab_id, "snapshot", { max_elements }); 162 + }, 163 + 164 + async text({ tab_id, max_length = 20000 }) { 165 + return pageCall(tab_id, "text", { max_length }); 166 + }, 167 + 168 + async click({ tab_id, ref }) { 169 + return pageCall(tab_id, "click", { ref }); 170 + }, 171 + 172 + async fill({ tab_id, ref, value }) { 173 + return pageCall(tab_id, "fill", { ref, value }); 174 + }, 175 + 176 + async press_key({ tab_id, key, ref = null }) { 177 + return pageCall(tab_id, "pressKey", { key, ref }); 178 + }, 179 + 180 + async evaluate({ tab_id, code }) { 181 + // The body runs as an async function in the isolated content-script 182 + // world; the JSON round trip turns DOM nodes etc. into a clear error 183 + // instead of a structured-clone failure. 184 + const [result] = await browser.tabs.executeScript(tab_id, { 185 + code: `(async () => { ${code}\n})().then((v) => JSON.stringify(v === undefined ? null : v))`, 186 + }); 187 + return JSON.parse(result); 188 + }, 189 + 190 + async screenshot({ tab_id }) { 191 + return browser.tabs.captureTab(tab_id, { format: "png" }); 192 + }, 193 + }; 194 + 195 + connect();
+19
tools/browser-mcp/extension/manifest.json
··· 1 + { 2 + "manifest_version": 2, 3 + "name": "Browser MCP", 4 + "version": "1.0.0", 5 + "description": "Lets local AI agents control browser tabs through the browser-mcp native messaging host, without WebDriver/Marionette.", 6 + "permissions": ["tabs", "nativeMessaging", "<all_urls>"], 7 + "background": { 8 + "scripts": ["background.js"] 9 + }, 10 + "browser_specific_settings": { 11 + "gecko": { 12 + "id": "browser-mcp@lassul.us", 13 + "strict_min_version": "142.0", 14 + "data_collection_permissions": { 15 + "required": ["none"] 16 + } 17 + } 18 + } 19 + }
+253
tools/browser-mcp/extension/page.js
··· 1 + /* Browser MCP content-side helpers. 2 + * 3 + * Injected on demand into the extension's isolated content-script world of a 4 + * page. Element refs are kept here (not as DOM attributes), so the page cannot 5 + * observe them; they live as long as the document. 6 + */ 7 + 8 + // eslint-disable-next-line no-var 9 + var __browserMcp = (() => { 10 + const refs = new Map(); // ref -> WeakRef(element) 11 + const elementRefs = new WeakMap(); // element -> ref 12 + let nextRef = 1; 13 + 14 + const INTERACTIVE = [ 15 + "a[href]", 16 + "button", 17 + "input:not([type=hidden])", 18 + "select", 19 + "textarea", 20 + "summary", 21 + "[contenteditable=''], [contenteditable=true]", 22 + "[role=button], [role=link], [role=checkbox], [role=radio], [role=switch]", 23 + "[role=tab], [role=menuitem], [role=menuitemcheckbox], [role=option]", 24 + "[role=combobox], [role=textbox], [role=searchbox], [role=slider]", 25 + "[tabindex]:not([tabindex='-1'])", 26 + "[onclick]", 27 + ].join(", "); 28 + const HEADINGS = "h1, h2, h3, h4, h5, h6"; 29 + 30 + function refFor(el) { 31 + let ref = elementRefs.get(el); 32 + if (ref === undefined) { 33 + ref = nextRef++; 34 + elementRefs.set(el, ref); 35 + refs.set(ref, new WeakRef(el)); 36 + } 37 + return ref; 38 + } 39 + 40 + function lookup(ref) { 41 + const el = refs.get(ref)?.deref(); 42 + if (!el || !el.isConnected) { 43 + throw new Error(`ref ${ref} is gone; take a new snapshot`); 44 + } 45 + return el; 46 + } 47 + 48 + function clip(s, n) { 49 + s = (s || "").replace(/\s+/g, " ").trim(); 50 + return s.length > n ? s.slice(0, n - 1) + "…" : s; 51 + } 52 + 53 + function accessibleName(el) { 54 + const labelledby = el.getAttribute("aria-labelledby"); 55 + if (labelledby) { 56 + const text = labelledby 57 + .split(/\s+/) 58 + .map((id) => document.getElementById(id)?.innerText || "") 59 + .join(" "); 60 + if (text.trim()) return text; 61 + } 62 + const direct = 63 + el.getAttribute("aria-label") || 64 + (el.labels && [...el.labels].map((l) => l.innerText).join(" ")); 65 + if (direct && direct.trim()) return direct; 66 + if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { 67 + return el.placeholder || el.title || ""; 68 + } 69 + return ( 70 + el.innerText || 71 + el.getAttribute("title") || 72 + el.getAttribute("alt") || 73 + el.querySelector?.("img[alt]")?.alt || 74 + "" 75 + ); 76 + } 77 + 78 + function roleOf(el) { 79 + const role = el.getAttribute("role"); 80 + if (role) return role; 81 + const tag = el.tagName.toLowerCase(); 82 + if (tag === "a") return "link"; 83 + if (tag === "input") return `input[${el.type}]`; 84 + if (/^h[1-6]$/.test(tag)) return `heading${tag[1]}`; 85 + if (el.isContentEditable) return "editable"; 86 + return tag; 87 + } 88 + 89 + function visible(el) { 90 + if ( 91 + el.checkVisibility && 92 + !el.checkVisibility({ visibilityProperty: true }) 93 + ) { 94 + return false; 95 + } 96 + const r = el.getBoundingClientRect(); 97 + return r.width > 0 && r.height > 0; 98 + } 99 + 100 + // Walks the document and open shadow roots in document order. 101 + function* walk(root) { 102 + for (const el of root.querySelectorAll("*")) { 103 + yield el; 104 + if (el.shadowRoot) yield* walk(el.shadowRoot); 105 + } 106 + } 107 + 108 + function describe(el) { 109 + const parts = [`[${refFor(el)}]`, roleOf(el)]; 110 + const name = clip(accessibleName(el), 80); 111 + if (name) parts.push(JSON.stringify(name)); 112 + const tag = el.tagName; 113 + if (tag === "INPUT" && (el.type === "checkbox" || el.type === "radio")) { 114 + parts.push(el.checked ? "checked" : "unchecked"); 115 + } else if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") { 116 + if (el.value) parts.push(`value=${JSON.stringify(clip(el.value, 80))}`); 117 + } 118 + if (tag === "A") parts.push(`-> ${clip(el.getAttribute("href"), 100)}`); 119 + if (el.disabled) parts.push("disabled"); 120 + return parts.join(" "); 121 + } 122 + 123 + function snapshot({ max_elements }) { 124 + const lines = []; 125 + let total = 0; 126 + for (const el of walk(document)) { 127 + if (!el.matches(INTERACTIVE) && !el.matches(HEADINGS)) continue; 128 + if (!visible(el)) continue; 129 + total++; 130 + if (lines.length < max_elements) lines.push(describe(el)); 131 + } 132 + const header = [`title: ${document.title}`, `url: ${location.href}`]; 133 + if (total > lines.length) { 134 + header.push(`(showing ${lines.length} of ${total} elements)`); 135 + } 136 + return [...header, "", ...lines].join("\n"); 137 + } 138 + 139 + function text({ max_length }) { 140 + const t = document.body ? document.body.innerText : ""; 141 + return t.length > max_length 142 + ? t.slice(0, max_length) + `\n… (${t.length - max_length} more chars)` 143 + : t; 144 + } 145 + 146 + function mouse(el, type, x, y) { 147 + const Ctor = type.startsWith("pointer") ? PointerEvent : MouseEvent; 148 + el.dispatchEvent( 149 + new Ctor(type, { 150 + bubbles: true, 151 + cancelable: true, 152 + composed: true, 153 + clientX: x, 154 + clientY: y, 155 + button: 0, 156 + buttons: type.endsWith("down") ? 1 : 0, 157 + view: window, 158 + }), 159 + ); 160 + } 161 + 162 + function click({ ref }) { 163 + const el = lookup(ref); 164 + el.scrollIntoView({ block: "center", inline: "center" }); 165 + const r = el.getBoundingClientRect(); 166 + const x = r.left + r.width / 2; 167 + const y = r.top + r.height / 2; 168 + mouse(el, "pointerdown", x, y); 169 + mouse(el, "mousedown", x, y); 170 + el.focus?.({ preventScroll: true }); 171 + mouse(el, "pointerup", x, y); 172 + mouse(el, "mouseup", x, y); 173 + el.click(); 174 + return `clicked ${describe(el)}`; 175 + } 176 + 177 + function fill({ ref, value }) { 178 + const el = lookup(ref); 179 + el.scrollIntoView({ block: "center" }); 180 + el.focus(); 181 + if (el.tagName === "SELECT") { 182 + const option = [...el.options].find( 183 + (o) => o.value === value || o.text.trim() === value, 184 + ); 185 + if (!option) throw new Error(`no option ${JSON.stringify(value)}`); 186 + el.value = option.value; 187 + } else if ( 188 + el.tagName === "INPUT" && 189 + (el.type === "checkbox" || el.type === "radio") 190 + ) { 191 + const want = value === "true" || value === "on" || value === "checked"; 192 + if (el.checked !== want) el.click(); 193 + return `set ${describe(el)}`; 194 + } else { 195 + // insertText goes through the editor like real typing (trusted input 196 + // events, works for frameworks and contenteditable editors). 197 + if (el.select) el.select(); 198 + else document.getSelection().selectAllChildren(el); 199 + const inserted = 200 + value === "" 201 + ? document.execCommand("delete") 202 + : document.execCommand("insertText", false, value); 203 + if (!inserted) { 204 + if (el.isContentEditable) el.textContent = value; 205 + else el.value = value; 206 + el.dispatchEvent(new Event("input", { bubbles: true, composed: true })); 207 + } 208 + } 209 + el.dispatchEvent(new Event("change", { bubbles: true })); 210 + return `filled ${describe(el)}`; 211 + } 212 + 213 + const KEY_CODES = { 214 + Enter: 13, 215 + Escape: 27, 216 + Tab: 9, 217 + Backspace: 8, 218 + Delete: 46, 219 + ArrowUp: 38, 220 + ArrowDown: 40, 221 + ArrowLeft: 37, 222 + ArrowRight: 39, 223 + " ": 32, 224 + }; 225 + 226 + function pressKey({ key, ref }) { 227 + const el = 228 + ref == null ? document.activeElement || document.body : lookup(ref); 229 + const init = { 230 + key, 231 + code: key.length === 1 ? `Key${key.toUpperCase()}` : key, 232 + keyCode: 233 + KEY_CODES[key] || 234 + (key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0), 235 + bubbles: true, 236 + cancelable: true, 237 + composed: true, 238 + }; 239 + const notCancelled = el.dispatchEvent(new KeyboardEvent("keydown", init)); 240 + if (key.length === 1 || key === "Enter") { 241 + el.dispatchEvent(new KeyboardEvent("keypress", init)); 242 + } 243 + el.dispatchEvent(new KeyboardEvent("keyup", init)); 244 + // Synthetic key events have no default action; do the one that matters. 245 + if (notCancelled && key === "Enter" && el.form && el.tagName === "INPUT") { 246 + el.form.requestSubmit(); 247 + return `pressed Enter on ${describe(el)}, submitted form`; 248 + } 249 + return `pressed ${key} on ${describe(el)}`; 250 + } 251 + 252 + return { snapshot, text, click, fill, pressKey }; 253 + })();