chrome-devtools
chrome-devtools is a profile-aware command-line wrapper for running Chrome DevTools MCP operations with isolated Chrome user data directories.
Use it instead of raw chrome-devtools-mcp when multiple agents need to share one Chrome profile: the daemon separates sessions by page, keeps snapshot uids scoped to each session, and respawns Chrome/MCP when the browser endpoint or MCP process fails.
The tool is designed to be called as a regular CLI from agent skills. It is not registered in Hermes as an MCP server.
Agent Skill Installation
This repository is also a single-skill repository. Install the skill with the Vercel Labs skills CLI:
Install it for Codex explicitly:
Install it globally:
For local development, install from a checkout:
Design
- Profiles are explicit: every operation that targets a browser profile requires
--profile <name>. - Profiles own their Chrome user data directory. The daemon picks a free DevTools port at start time and records it under
~/.cache/chrome-devtools/daemons/<profile>.port. - On Linux with a systemd user manager, the daemon and the Chrome it launches each run in their own transient scope (
chrome-devtools-<profile>-daemon-<n>.scope/chrome-devtools-<profile>-chrome-<n>.scope) viasystemd-run --user --scope. Chrome's browser process moves itself toapp-com.google.Chrome-<pid>.scope, but its zygote, GPU, renderer and utility processes stay in the cgroup of whatever started Chrome; giving them a scope of their own keeps them alive when the agent session that randaemon startends and its unit is stopped. Without that, Chrome cannot relaunch its GPU process and exits withGPU process isn't usable. Goodbye. - Chrome's stdout and stderr go to
~/.cache/chrome-devtools/daemons/<profile>.chrome.log. Each Chrome launch moves the previous file to<profile>.chrome.log.1, so the output of a Chrome that just crashed is kept after the daemon relaunches it. - Profiles are read from
~/.config/chrome-devtools/config.toml. - If the config file is missing on startup, the CLI creates a
defaultprofile using~/.config/chrome-devtools/profiles/default. user_data_diris optional; when omitted, it defaults to~/.config/chrome-devtools/profiles/<profile-name>.- Prefer
user_data_dirvalues under~/.config/chrome-devtools/profiles/<profile-name>so Chrome profile data stays with the tool config. - The CLI may execute
chrome-devtools-mcpinternally for a selected profile. - MCP JSON-RPC input and output are routed through one long-lived per-profile daemon by default.
- Hermes-side Chrome DevTools MCP registration is not required for this workflow.
- Daemon-routed snapshot
uidvalues are session uid tokens. Keeptake_snapshot -> click/fillon the same daemon session. - The daemon owns one
chrome-devtools-mcpprocess per profile, so multiple CLI invocations do not create competing MCP processes for the same Chrome profile. If Chrome's DevTools endpoint or MCP probe fails, the daemon respawns MCP and drops in-memory sessions so clients can mint fresh ones. - The daemon accepts control commands independently of MCP forwarding, so
session create,session list,session close,daemon status, anddaemon stoprespond while clients are bound. - The daemon rewrites client JSON-RPC ids internally and restores the original id in responses, including string ids.
mcp direct-callandmcp direct-listremain available as a fallback and take a per-profile lock under~/.cache/chrome-devtools/locks.mcp callandmcp batchrequire an explicit--session <id>minted bysession create. Sessions live in-memory on the daemon and expire after 30 minutes of inactivity (24 hours for a session that owns no tab); anunknown sessionerror says whether the session was reaped, closed, dropped by an MCP respawn, or never existed on this daemon. Concurrent agents must mint and use their own session ids; the daemon assigns each session to a page and injectspageIdfor page-scoped tools.
Configuration
[[]]
= "default"
Environment overrides:
CHROME_DEVTOOLS_MCP_MAX_OLD_SPACE_MB: Node heap limit for the daemon-ownedchrome-devtools-mcpprocess. Default:2048.CHROME_DEVTOOLS_CONTROL_WARN_LATENCY_MS: control command warn threshold. Default:1000.CHROME_DEVTOOLS_FORWARD_WARN_LATENCY_MS: MCP forward warn threshold. Default:30000.CHROME_DEVTOOLS_DIAGNOSTIC_WINDOW_SECS: daemon status latency window. Default:600.CHROME_DEVTOOLS_CHROME_EXTRA_ARGS: extra whitespace-separated flags appended to the Chrome command line at launch.CHROME_DEVTOOLS_MCP_HEAVY_REQUEST_TIMEOUT_SECS: request timeout for heavy tools (take_snapshot,take_screenshot,performance_*). Default:300.
Heavy-tool note: Chrome's Accessibility.getFullAXTree (used by take_snapshot) scales super-linearly with DOM size — pages with 100k+ nodes can take minutes. Timing out early does not help: chrome-devtools-mcp does not abort a cancelled tool call and holds its internal tool mutex until the call finishes, so subsequent tools of the same daemon queue behind it anyway. That is why heavy tools get a longer deadline. On very large pages prefer evaluate_script for targeted inspection over full snapshots.
Chrome is always launched with --disable-features=PasswordLeakDetection: the native "Change your password" dialog is invisible to MCP snapshots and blocks page input, which makes automated actions appear to hang. Chrome flags only apply when the daemon starts a new Chrome process — an already-running Chrome for the profile is reused as-is, so after upgrading run chrome-devtools daemon stop --profile <name> followed by chrome-devtools profile stop --profile <name> (kills Chrome) once.
Daemon-native tools
Besides forwarding the chrome-devtools-mcp tool set, the daemon implements ten tools of its own directly over CDP (they are listed in tools/list and called like any MCP tool). They run on the session's page without the MCP process, so they keep working while a heavy MCP tool is in flight and never trigger an accessibility snapshot:
goto {url, waitSelector?, waitExpression?, timeout?, interval?}: navigate the session page without foregrounding Chrome and wait in the same call. With no explicit wait condition it waits fordocument.readyState === 'complete'. A wait that times out reports the current URL, title,readyStateand the condition's value.screenshot_quiet {filePath, format?, quality?}: capture the visible viewport directly withPage.captureScreenshot, bypassing the MCP queue and leaving the user's active tab alone.filePathmust be absolute and under$HOMEor the OS temp directory.wait_for_js {expression | selector, timeout?, interval?}: poll a JS expression (or CSS selector) until truthy — replaces fixed sleeps for SPA waits.click_selector {selector}: scroll the first match into view and click its center. Inside iframes the click point is translated to the top frame's viewport.click_at {x, y}: raw left click at viewport coordinates.dispatch_key {key, modifiers?}: single key press (Escape, Enter, arrows, single characters; modifiers bitmask Alt=1 Ctrl=2 Meta=4 Shift=8).set_file_input {selector, filePaths}: attach files to theinput[type=file]matched by a selector throughDOM.setFileInputFiles. No snapshot and no file chooser, so it picks the exact input on pages that have several and never crosses into another session's page. The element is resolved throughRuntime.evaluate, so it works on pages with a large DOM (Gmail, for one). Paths must be absolute and under$HOMEor the OS temp directory.print_pdf_quiet {filePath, landscape?, printBackground?, scale?}: save the session page as a PDF withPage.printToPDF, without foregrounding the tab. Use it to keep invoices, receipts and emails as they were rendered. Same path rules asscreenshot_quiet.close_page_quiet {}: close the session's own tab through the DevTools HTTP endpoint.close_pagequeues behind the MCP tool mutex, so it cannot reach a tab that is holding the mutex open (a JavaScript dialog, a tool that never returns). Closing that tab releases the mutex, and the session picks up a fresh tab on its next call.
JavaScript dialogs
A session's tab is watched for JavaScript dialogs from the moment the daemon assigns it. alert and beforeunload are accepted, confirm and prompt are dismissed, and each one is logged as dialog_handled session=... target=... type=... accept=....
This runs on a CDP connection the daemon keeps open for the tab, because Chrome tracks a pending dialog per client: a connection that was not listening when the dialog opened gets No dialog is showing and cannot close it. Without the watcher, an open dialog holds the MCP tool mutex and every later request on the profile waits behind it.
If the watcher cannot attach, the daemon logs dialog_watch_failed and carries on; recover a stuck tab with close_page_quiet.
type_into {selector, text, clear?}: focus the matched element and insert text withInput.insertText. Use it when a native value setter plus aninputevent does not reach framework state (some React-based forms).
click_selector, set_file_input and type_into reach into iframes with >>> in the selector (iframe#form >>> input[name=email], nested as iframe#a >>> iframe#b >>> input), for same-origin and cross-origin (out-of-process) iframes alike. A missing element is reported with the frame, its URL and the iframes it contains; could not focus selector also says whether the element is hidden, disabled or read-only.
Each session is pinned to the CDP target id of its tab, which stays the same for the life of that tab. Navigation, redirects and SPA route changes alter the URL but not the target, tabs that share a URL are told apart (new_page and select_page pin the target id), and daemon-native tools never fall back to another tab: if the session's tab is gone they fail with session page target <id> is gone instead of acting on somebody else's page. This matters when several agents share one Chrome profile.
Commands
session create starts the profile daemon when needed and mints a new in-memory session id. The session expires after 30 minutes of inactivity or when the daemon stops. Output looks like session=sess-xxxxxxxxxxxxxxxx created=<ts> last_used=<ts> owned=false.
session list prints one line per active session. session close drops the named session when it is idle and closes its daemon-created page when Chrome has another tab.
mcp list starts the profile daemon when needed, queries tools/list through that daemon, and prints the raw MCP JSON response.
mcp call binds the given session, forwards stdin JSON-RPC lines to the daemon-owned chrome-devtools-mcp process, waits for the matching JSON-RPC responses, and prints them to stdout. The session is held for the lifetime of this invocation; other clients can use control commands while the bind is active, and another MCP bind waits up to CHROME_DEVTOOLS_BIND_TIMEOUT_SECS. Activity refreshes the session's idle timer.
daemon status includes queued_mcp_requests, max_control_latency_ms, and max_forward_latency_ms for the recent diagnostic window. The default window is 600 seconds and can be changed with CHROME_DEVTOOLS_DIAGNOSTIC_WINDOW_SECS.
upload_file first uses chrome-devtools-mcp. When MCP reports that direct upload and file chooser triggering both failed, the daemon sets the page's input[type=file] through Chrome DevTools Protocol as a fallback and logs upload_file_fallback.
mcp batch binds the given session and reads a JSON array of steps from --script, running each step in order through the profile daemon. One initialize handshake is performed; then each tool step is issued as a tools/call and each sleep_ms step pauses the runner. Results are printed as a JSON array to stdout (or to --output <path>), so callers can inspect them programmatically without parsing line-delimited JSON-RPC. Step shapes:
Inside args, replace any value with {"$ref":"<label>.<path>"} to substitute it with a previous step's result. <path> is dot-separated; numeric segments index arrays. For example, {"$ref":"snap.result.content.0.text"} resolves to the text of the first content entry of the result whose label was snap.
By default a tool step that returns an error (non-null error field or isError: true) is recorded and the batch continues. An Execution context was destroyed error (the step navigated the page) is recorded as "navigated": true with error: null and result.isError: false instead. The script is validated before the first step runs, so a tool step named sleep_ms, sleep or wait, or a step without type, fails the batch up front with the list of step types. Pass --fail-fast (or "on_error": "stop" on a step) to abort on the first error; partial results are still written and chrome-devtools exits non-zero.
Use --script - to read the batch from stdin instead of a file, and --output <path> to write the JSON results to a file instead of stdout.
Important: daemon-routed take_snapshot result uid values use the form u:<session>:<epoch>:<raw-uid>. Pass those tokens back to later click/fill calls in the same session. Tokens from another session or an older snapshot are rejected before the request reaches MCP.
daemon start starts one background broker for the profile. Daemon metadata lives under ~/.cache/chrome-devtools/daemons. daemon stop asks the broker to stop and cleans up its socket/pid files.
mcp direct-call and mcp direct-list bypass the daemon and run chrome-devtools-mcp directly (while the profile daemon runs it holds the profile lock, so direct-list lists the tools through the daemon instead). Use them only for fallback/manual debugging; they cannot preserve snapshot state across independent process invocations.
See docs/session-daemon-design.md for the per-profile daemon/broker design and future session ownership direction.
mcp help prints MCP-specific usage, examples, and notes about stdio JSON-RPC forwarding.
Development
This repository intentionally provides Rust through the Nix flake development shell. Do not assume cargo is installed globally.