Claude Code Browser Automation — Chrome DevTools MCP (Ubuntu 22.04 + DDEV)

Overview

This document explains how to let Claude Code “see” and drive a real browser so it can verify frontend changes against a live DDEV site, instead of relying on curl/grep-based markup inspection. It is a step-by-step tutorial for a specific, simple machine topology:

  • Ubuntu 22.04.5 LTS host — where both Claude Code and the browser run
  • DDEV — Docker-based local dev stack serving one or more https://*.ddev.site sites

Prerequisites

  • Ubuntu (any recent LTS — this was done on 22.04.5)
  • DDEV with at least one project running (ddev start), reachable over HTTPS
  • Claude Code installed on the host (claude on $PATH, e.g. ~/.local/bin/claude)
  • curl and unzip available (both are part of a stock Ubuntu install)
  • mkcert‘s local CA already trusted system-wide — this is normally already true if DDEV’s HTTPS has been working in your regular browser without warnings (DDEV runs mkcert -install the first time it needs to). Verify with mkcert -CAROOT — if that prints a path and /usr/local/share/ca-certificates/ has an mkcert_development_CA_*.crt file in it, you’re set.

Part 1 — Install Node.js on the host (nvm)

chrome-devtools-mcp runs via npx on the host, not inside a DDEV container — a project’s own containerized Node (if any) is irrelevant here.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash

# nvm patches your shell rc file (~/.bashrc or ~/.zshrc). In a NEW terminal,
# or by sourcing it in the current one:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

nvm install --lts
nvm alias default 'lts/*'

# verify
node --version   # v24.20.0
npm --version     # 11.19.0

Finding: this host had no Node at all before this. nvm avoids sudo entirely and keeps this Node install isolated from any project’s own dependency management.

Part 2 — Install Chrome for Testing on the host

mkdir -p ~/.local/share/chrome-for-testing
cd ~/.local/share/chrome-for-testing

# 1. Resolve the latest stable Chrome-for-Testing linux64 download URL
URL=$(curl -s https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json \
  | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const j=JSON.parse(d);console.log(j.channels.Stable.downloads.chrome.find(x=>x.platform==='linux64').url)})")

# 2. Download and extract
curl -sL -o chrome-linux64.zip "$URL"
unzip -q -o chrome-linux64.zip
rm -f chrome-linux64.zip

# 3. Restore executable bits (zip extraction does not always preserve them)
find ~/.local/share/chrome-for-testing/chrome-linux64 -type f -exec chmod +x {} +

# 4. Sanity check
~/.local/share/chrome-for-testing/chrome-linux64/chrome --version

Real launch test against a DDEV site, before touching any MCP config

Do this before wiring up the MCP server — if a system library is missing, you want to find out here, isolated from everything else:

timeout 20 ~/.local/share/chrome-for-testing/chrome-linux64/chrome \
  --headless=new --no-sandbox --disable-gpu --dump-dom \
  "https://your-project.ddev.site/some/public/page"

Findings on this specific host (Ubuntu 22.04.5, native — not WSL2):

  • No missing system libraries. The reference WSL2/Ubuntu-24.04 tutorial needed sudo apt-get install -y libasound2t64 (a package specific to 24.04’s 64-bit time_t transition) before headless Chrome would launch at all. On this Ubuntu 22.04.5 host, the very first headless launch worked cleanly — no apt step was needed.
  • unzip was already available, so the zip could be extracted directly — no need for the Python zipfile workaround the WSL tutorial required (its container was missing unzip).
  • The DDEV certificate was already trusted. mkcert -CAROOT showed the CA was already in the system trust store (/usr/local/share/ca-certificates/), so --dump-dom returned the real page DOM immediately — no privacy-error interstitial. This makes sense on native Ubuntu: the same OS-level trust store Chrome for Testing reads from is the one DDEV/mkcert already populated when HTTPS first worked in your regular browser. (On WSL2, the Linux side and the Windows-hosted browser don’t share a trust store, so this had to be handled separately.)

If your own launch test does show a missing-library error or a certificate warning, resolve that specific thing before moving on — don’t pre-install anything speculatively.

Part 3 — Register Chrome DevTools MCP with Claude Code

Claude Code has an official, documented install path for this exact tool (ChromeDevTools/chrome-devtools-mcp):

claude mcp add chrome-devtools --scope user -- \
  npx -y chrome-devtools-mcp@latest \
  --headless --isolated --acceptInsecureCerts \
  --executablePath ~/.local/share/chrome-for-testing/chrome-linux64/chrome
FlagWhy
--scope userRegisters the server for this user across every project, not just the current repo — this is personal tooling, not something to commit to a project’s .mcp.json (an --executablePath pointing at this machine’s ~/.local/share/... would break for anyone else). Nothing in the project’s git history changes.
--headlessNo GUI/window — fine on a workstation too, since you interact through screenshots/snapshots, not a visible window.
--isolatedUses a temporary user-data-dir that’s cleaned up when the browser closes. Good default when the goal is visual/style verification, not persisting an authenticated session across days. Drop it if you want a persistent profile (e.g. to skip logging in every session).
--acceptInsecureCertsDefensive — tolerates self-signed/expired certs. Cost-free to include even if (as here) the cert is already trusted by the system store.
--executablePathPoints at the Chrome for Testing binary from Part 2 — chrome-devtools-mcp does not bundle or auto-download a browser itself.

Other flags worth knowing about (see docs/configuration.md in the tool’s repo): --channel (canary/dev/beta/stable, if you want to point at a real installed Chrome channel instead of Chrome for Testing), --browserUrl/-u (attach to an already-running, debuggable Chrome instead of launching a new one — the equivalent of the “visible Windows Chrome over CDP” alternative some tutorials use), --viewport, --logFile, --proxyServer.

Real gotcha hit here: Claude Code’s process doesn’t see your shell’s PATH

claude mcp add chrome-devtools --scope user -- npx -y chrome-devtools-mcp@latest ... (using a bare npx, relying on $PATH) failed:

✘ Failed to connect — ENOENT: Executable not found in $PATH: "npx"

Cause: nvm modifies your interactive shell’s startup file (~/.zshrc here). Claude Code’s own process was already running and doesn’t re-source that file, so it never saw nvm’s PATH additions — this has nothing to do with WSL, it’s simply that Claude Code’s process environment is separate from your terminal’s.

Using npx‘s absolute path instead got further, but then failed differently:

/usr/bin/env: 'node': No such file or directory

Cause: npx‘s own script has a #!/usr/bin/env node shebang. Even with npx’s own path fully qualified, env still resolves node by searching $PATH — and node‘s directory wasn’t there either.

Fix: symlink node, npm, and npx into ~/.local/bin/ — a directory that is already on Claude Code’s base PATH (it’s where the claude binary itself resolves from, so it’s guaranteed to be present without depending on any shell rc file being sourced):

ln -sf "$(dirname "$(nvm which default 2>/dev/null || echo ~/.nvm/versions/node/*/bin/node)")"/node ~/.local/bin/node
ln -sf "$(dirname "$(nvm which default 2>/dev/null || echo ~/.nvm/versions/node/*/bin/node)")"/npm  ~/.local/bin/npm
ln -sf "$(dirname "$(nvm which default 2>/dev/null || echo ~/.nvm/versions/node/*/bin/node)")"/npx  ~/.local/bin/npx

# or, more simply, once you know the exact version installed:
ln -sf ~/.nvm/versions/node/v24.20.0/bin/node ~/.local/bin/node
ln -sf ~/.nvm/versions/node/v24.20.0/bin/npm  ~/.local/bin/npm
ln -sf ~/.nvm/versions/node/v24.20.0/bin/npx  ~/.local/bin/npx

After that, claude mcp remove chrome-devtools --scope user + re-running the claude mcp add command above (now resolving npx via the base PATH, which in turn resolves node the same way) connected successfully:

claude mcp list
# chrome-devtools: npx ... - ✔ Connected

This symlink approach is preferable to hardcoding nvm’s version-specific path into the MCP command itself (/home/you/.nvm/versions/node/v24.20.0/bin/npx) — a later nvm install --lts that bumps the default version wouldn’t silently break the MCP registration, since the symlinks can just be refreshed independently.

Part 4 — Restart Claude Code, then verify

Claude Code (like most MCP clients) loads MCP servers at startup. If you added the server mid-session, its tools will not appear until you restart — close and reopen the app/window (or start a fresh session).

Once restarted, the tools appear as mcp__chrome-devtools__*navigate_page, take_screenshot, take_snapshot, list_console_messages, list_network_requests, evaluate_script, click, fill, wait_for, and more (28 tools in the version used here).

Authenticating against a real Drupal/DDEV site

No CAPTCHA-disabling step was needed for this project (plain Drupal core login, no reCAPTCHA) — the practical equivalent here is generating a one-time login link and navigating straight to it:

ddev drush uli --uri=https://your-project.ddev.site
# → https://your-project.ddev.site/user/reset/<uid>/<timestamp>/<hash>/login

Then simply navigate_page to that URL — Drupal logs the session in and redirects. The link is single-use, so generate a fresh one each session if the previous one may already have been consumed.

A representative smoke test (what was actually run here)

navigate_page → https://your-project.ddev.site/user/login
list_console_messages → <no console messages found>
take_screenshot
evaluate_script → read getComputedStyle(...) on a real element

This is more than a health check — it caught a real, previously-undetected bug within the very first real page load: a themed button was rendering Bootstrap’s default blue instead of the project’s brand color, because Bootstrap 5.3’s component classes (.btn-primary, etc.) don’t inherit from the root --bs-primary CSS variable the way a grep over the aggregated stylesheet had suggested — each component defines its own hardcoded local variables. evaluate_script reading the actual computed background-color surfaced this immediately; no amount of curl + grep inspection would have.

Takeaway: treat evaluate_script/take_screenshot as a first-class verification step for any visual/CSS change, not just a way to eyeball things — reading real computed styles catches classes of bugs that markup/source inspection structurally cannot.

Key findings & pitfalls (summary)

#FindingImpact
1Native Ubuntu has no host/browser boundary to crossSkips nearly all of the WSL2-specific setup complexity
2Host had no Node at allInstalled via nvm (no sudo)
3No missing system libraries needed for headless launchNo apt step required on this Ubuntu 22.04.5 host (contrast with the libasound2t64 needed on 24.04)
4unzip was already presentNo Python zipfile workaround needed
5mkcert’s CA was already in the system trust store--acceptInsecureCerts kept only as a zero-cost safety net, not a hard requirement
6Only a snap-packaged Chromium was pre-installedDeliberately not used — snap confinement is a known source of CDP friction; installed Chrome for Testing instead
7Claude Code’s process doesn’t inherit your shell’s PATHclaude mcp add npx ... fails with ENOENT unless an absolute path is used
8npx‘s shebang re-resolves node via $PATH independentlyAbsolute-pathing npx alone isn’t enough — node needs to be reachable too
9~/.local/bin is on Claude Code’s base PATHSymlinking node/npm/npx there fixes both PATH issues at once, without touching shell rc files or hardcoding a version-specific nvm path
10MCP servers load at Claude Code startupMust restart the app/session after claude mcp add before new tools appear
11evaluate_script against computed styles found a real bug immediatelyComponent-local CSS variables not inheriting from theme root variables — invisible to source/markup grep, obvious on first real render

After a reboot / fresh start

Everything lives on the host filesystem and survives reboots — nothing needs reinstalling:

ComponentLocationPersists
Node / nvm~/.nvm (v24.20.0)
Chrome for Testing~/.local/share/chrome-for-testing/
node/npm/npx symlinks~/.local/bin/
MCP server registration~/.claude.json (user scope)
Browser session/profilenot persisted — --isolated was chosen, so each Claude Code session starts a fresh Chrome profileby design

Startup checklist:

# 1. Bring up whichever DDEV project(s) you need
ddev start

# 2. Start/restart Claude Code — the chrome-devtools MCP server auto-spawns
#    (enabled by default once registered), and headless Chrome launches
#    lazily on the first browser tool call. No per-session browser setup.

Then just ask Claude to navigate somewhere — e.g. “open the login page and check the console for errors.”

Troubleshooting

SymptomFix
ENOENT: Executable not found in $PATH: "npx"Claude Code’s process doesn’t see your shell PATH — use an absolute path for npx, or (better) symlink node/npm/npx into ~/.local/bin
/usr/bin/env: 'node': No such file or directorySame root cause as above, one layer deeper (npx’s own shebang) — same fix
Chrome launch fails with a missing .so errorA system library is genuinely missing on your distro/version — apt-get install the specific package named in the error, not a bulk set of “just in case” packages
A privacy/certificate-error page instead of real contentThe DDEV cert isn’t trusted — check mkcert -CAROOT and that its CA is in /usr/local/share/ca-certificates/, or fall back to --acceptInsecureCerts
New MCP tools don’t show up after claude mcp addConfig is only read at startup — restart Claude Code (or start a new session)
claude mcp list shows the server but tools are still missingSame as above — a list/add succeeding is a config-file check, not proof the current session has loaded it
A themed color/style doesn’t look right on screenshot but the CSS “should” be correctRead the actual computed style (evaluate_script + getComputedStyle), don’t trust source-level grep — component frameworks (Bootstrap 5.3 here) can define component-local variables that don’t inherit from theme-level ones

Security & operations notes

  • Everything runs locally on the host; nothing about this setup sends data anywhere beyond what the target DDEV site itself would (no external CrUX/telemetry endpoints were configured either way).
  • The MCP server is registered at --scope user, not --scope project — it is not part of this (or any) repository’s version control, and won’t be installed for anyone else who clones a project.
  • --isolated means no authenticated session/cookie is retained between Claude Code restarts — each session that needs to be logged in generates (or is given) a fresh one-time login link.
  • Chrome for Testing should be refreshed periodically by re-running Part 2 (it always resolves “latest stable” at download time).

Verification checklist

  • [ ] node --version → resolves on the base PATH (~/.local/bin/node symlink), not just inside an nvm-sourced shell
  • [ ] ~/.local/share/chrome-for-testing/chrome-linux64/chrome --version runs without error
  • [ ] claude mcp list shows chrome-devtools ... ✔ Connected
  • [ ] After restarting Claude Code, mcp__chrome-devtools__* tools are available (verify via a tool-search or by attempting to call one)
  • [ ] Claude can navigate_page to a real *.ddev.site URL and get real content back (not a certificate warning)
  • [ ] list_console_messages returns cleanly (or shows real, expected messages) for a known-good page
  • [ ] take_screenshot produces a real, readable image of the page
  • [ ] evaluate_script can read a computed style back from the page — this is the check that actually caught a real bug on this project on the very first try