chrome-mcp
Drive a real Chrome browser from Claude (or any MCP host). One pluggableExecutor interface, two backends:
- Extension (primary): an MV3 extension drives your real Chrome — reallogins, real cookies — via
chrome.scripting/chrome.tabs. The CLI runs alocalhost WebSocket server; the extension dials in. - CDP fallback: when no extension is paired, the CLI launches/attaches aPlaywright-driven Chromium for scripted/headless use.
Distributed as an npx CLI (the MCP server) plus a load-unpacked extension.
Full design:
docs/BLUEPRINT.md— architecture, wireprotocol, the complete tool surface, the extension manifest, the securitymodel, and the phased build plan.
Quickstart
1. Register the MCP server with your host (e.g. Claude Desktop / Code):
{
"mcpServers": {
"chrome-mcp": {
"command": "npx",
"args": ["-y", "@mehmoodqureshi/chrome-mcp", "--allow-domain", "example.com", "--enable-mutations"]
}
}
}
By default everything is deny-all (no domains, no eval, no mutations). Grantexactly what you need with --allow-domain <glob> (repeatable), --enable-mutations,--enable-downloads, --enable-uploads, --unsafe-enable-eval, or --unsafe-all-domains.
--enable-uploadspermitsupload_file(setting local file(s) on a page's file<input>). It is off by default because sending local files to a page is anexfiltration risk; it is also gated by the destination-domain allowlist. Pair itwith--uploads-dir <path>to restrict uploads to files inside that directory(..traversal is blocked) — strongly recommended for unattended use.
Drive only your real Chrome (recommended for the extension). Add--no-cdp-fallback so the server never launches a separate Chromium, and--persist-token so the pairing token survives restarts — pair once, neveragain:
{
"mcpServers": {
"chrome-mcp": {
"command": "npx",
"args": ["-y", "@mehmoodqureshi/chrome-mcp",
"--allow-domain", "example.com", "--enable-mutations",
"--no-cdp-fallback", "--persist-token"]
}
}
}
Without --persist-token a fresh token is minted every boot (the securedefault), which means re-pairing the extension on each restart. With it, thetoken is stored 0600 at ~/.chrome-mcp/token and reused; the extension'skeepalive auto-reconnects with no manual step. CHROME_MCP_TOKEN pins the tokenexplicitly (and is never written to disk).
2. Load the extension (to drive your real Chrome): build it, thenchrome://extensions → enable Developer mode → Load unpacked → selectextension-dist/.
3. Pair it: run npx chrome-mcp --print-pairing to write the handshake andprint its path, open the extension's Options page, and paste the port +token from ~/.chrome-mcp/handshake.json. (Without the extension, the CLIfalls back to a Playwright-driven Chromium automatically.)
The tools cover tabs, navigation, interaction (click/type/press/hover/scroll/select_option), reads (get_text/get_html/screenshot/eval/wait_for),an accessibility snapshot (interactive elements with stable refs the model cantarget instead of guessing CSS selectors), session access (get_cookies/storage),helpers (extract_links/read_as_markdown/fill_form/download_file/upload_file),and chrome_status. upload_file sets local file(s) on a file <input> without theOS dialog (requires --enable-uploads).
click/type accept trusted: true for real OS-level input (works onReact/Vue controlled inputs); interactions auto-wait for the target to appear.
Driving several tabs at once — batch
batch runs many tool calls in one request — parallel (default) orserial (with optional stopOnError). Each sub-op goes through the samepolicy gate, rate limit, and error handling as a direct call (no bypass,no nesting). Use it to fan work out across tabs:
// open three product pages (background, so they don't fight for focus)…
{ "name": "batch", "arguments": { "ops": [
{ "tool": "tab_new", "args": { "url": "https://a.example/p" } },
{ "tool": "tab_new", "args": { "url": "https://b.example/p" } },
{ "tool": "tab_new", "args": { "url": "https://c.example/p" } }
]}}
// …then read them all at once (wall-clock ≈ the slowest one, not the sum)
{ "name": "batch", "arguments": { "ops": [
{ "tool": "get_text", "args": { "tabId": "<a tabId>" } },
{ "tool": "get_text", "args": { "tabId": "<b tabId>" } },
{ "tool": "get_text", "args": { "tabId": "<c tabId>" } }
]}}
In parallel mode, tab-scoped ops must pass an explicit tabId — theactive-tab default is unsafe under concurrency, so it's rejected rather thansilently mis-routed. (tab_new, tabs_list, chrome_status are exempt.)
tab_newfocuses the new tab by default (so "open X" behaves like openinga link, instead of replacing your current page — usetab_new, notnavigate, to open without losing the current tab). Passactive: falsetoopen in the background; parallel batches do this automatically.
Status
v0.5.0 — safe multi-tab concurrency. Adds the batch fan-out tool, makesparallel tab automation race-free (explicit-tabId guard; per-tabchrome.debugger serialization; collision-free tab_new), captures screenshotsvia chrome.debugger (a specific tab without stealing focus — plus truefull-page and element capture), and focuses newly opened tabs by default. 111automated tests + a gated headed extension smoke.
v0.2.0 — all six build phases complete and green. End-to-end working:npx chrome-mcp ⇄ bridge ⇄ extension ⇄ your real Chrome, with a Playwright CDPfallback. v0.2 adds the accessibility snapshot + element refs, auto-wait,cookies/storage/select_option, trusted input (chrome.debugger), a toolbarstatus badge, and a stable pairing token (--persist-token).
- Phase 0 — Contracts & skeleton:
shared/protocol.ts(wire contract),src/executor/types.ts(Executor interface),src/security/policy.ts(default-deny policy + capability gates),src/config.ts(CLI/env/policyresolution), build + test harness. - Phase 1 — MCP server + StubExecutor:
mcp/server.ts(clean-stdoutstdio),mcp/tools.ts(28-tool catalog + never-throw dispatch +drift-check), validators/envelopes/helpers,ExecutorManager+StubExecutor,cli.ts. Point an MCP host atnode dist/src/cli.jstoday. - Phase 2 — WebSocket bridge + auth:
bridge/server.ts(loopback WS,hello-token gate, welcome/unauthorized, displacement),bridge/auth.ts(per-boot 256-bit token, atomic-0600 handshake, SHA-256timingSafeEqual),bridge/connection.ts(id-correlation, method-aware timeouts, backpressure,reject-all-on-close, heartbeat). - Phase 3 — ExtensionExecutor + CdpExecutor + selection:
executor/extension-executor.ts(Executor over the bridge),executor/cdp-executor.ts(Playwright connect/launch + lock recovery +tab resolution),executor/select.ts(extension-if-ping-responsive elseCDP). CLI now starts the bridge, writes the 0600 handshake, and serves areal backend. Addsplaywright. - Phase 4 — MV3 extension:
extension/—manifest.json,sw/ws-client.ts(dial + hello/welcome + pong),sw/executor.ts(chrome.scripting/chrome.tabs command impls),sw/router.ts(never-throw +drift),sw/background.ts(top-level listeners + 25s keepalive/reconnect),options page (manual pairing), esbuild build →extension-dist/. Verifiedby a live--load-extensionsmoke (pair → navigate → get_text). Addsesbuild+@types/chrome. - Phase 5 — Helpers, downloads, HITL: hardened
download_file(shared/download.ts— path-traversal/dangerous-ext sanitize + size cap,wired into both backends), richerread_as_markdown, and a human-in-the-loopharness (hitl/—npm run test:hitl [-- --include-mutating]) with pure,unit-tested gating. 50 automated tests. - Phase 6 — Packaging & docs:
fileswhitelist (shipsdist/src,dist/shared,extension-dist, LICENSE, blueprint — not source/tests),prepackbuild,bin, quickstart +.mcp.jsonsnippet. Verified by atarball install smoke (npm pack→ install → MCPtools/list).
Security posture (default)
Deny-all safe mode. With no policy configured: empty domain allowlist,eval off, downloads off, mutating tools off. Opt in explicitly:
chrome-mcp --allow-domain example.com --enable-mutations
chrome-mcp --policy ./policy.json # see policy.example.json
chrome-mcp --unsafe-all-domains # loud footgun
The per-boot 256-bit token in ~/.chrome-mcp/handshake.json (mode 0600) is theonly trust boundary; it is never written to stdout/stderr.
Develop
npm install
npm run typecheck # server/test sources
npm run typecheck:ext # extension sources (@types/chrome)
npm run build:ext # bundle the extension → extension-dist/
npm test # builds, then runs node --test on dist/test
RUN_EXT_SMOKE=1 node --test dist/test/extension-smoke.test.js # live, headed
The extension
extension/ builds (esbuild) to extension-dist/, loaded viachrome://extensions → Load unpacked → select extension-dist/. Pair itfrom the extension's Options page using the port + token from~/.chrome-mcp/handshake.json (run npx chrome-mcp --print-pairing to get thepath).
Reads/interaction use
chrome.scripting/chrome.tabs— no "is beingdebugged" banner, CSP-safe reads (isolated world), testable under Playwright.chrome.debuggeris used only where it's needed and worth it:trusted: trueinput (real OS-level events on React/Vue inputs) andscreenshot(captures aspecific tab without activating it — safe under parallelbatch— withtrue full-page and element capture). Those ops briefly show the debug bannerwhile attached.