chrome-devtools MCP from WSL: driving (and auto-launching) a Windows Chrome

chrome-devtools MCP from WSL: driving (and auto-launching) a Windows Chrome

·10 min read·Updated on May 29, 2026

The problem: chrome-devtools MCP in WSL wants a Linux Chrome

The chrome-devtools MCP lets the agent inspect pages on its own — console, network requests, screenshots — without leaving the terminal. But its default mode connects over --remote-debugging-pipe and launches its own Chrome inside the Linux environment. Under WSLg, that produces a near-dead Chrome window: it renders, but the keyboard doesn't follow. In practice, you can't type a password on a login screen. The moment a human action is involved (login, captcha, 2FA), it's over.

What you actually want is the exact opposite: drive the real Windows Chrome, the one you normally type in, and let the MCP inspect it in parallel.

The fix: aim at the Windows Chrome

With networkingMode=mirrored, Windows and WSL share localhost. The idea is two steps: launch a Chrome on the Windows side with the debug port open, and tell the MCP to attach via --browserUrl http://127.0.0.1:9222.

The MCP config, in ~/.claude.json:

"chrome-devtools": {
  "type": "stdio",
  "command": "npx",
  "args": [
    "chrome-devtools-mcp@latest",
    "--browserUrl", "http://127.0.0.1:9222",
    "--acceptInsecureCerts"
  ],
  "env": {}
}

--acceptInsecureCerts isn't decoration: it lets you load self-signed local domains (*.dev.fransys.io behind Caddy) without Chrome refusing the certificate.

This is word for word the WSL method the official chrome-devtools-mcp README has documented since it went 1.x (May 2026): mirrored networking, chrome.exe --remote-debugging-port=9222, --browser-url http://127.0.0.1:9222.

The networking prerequisite nobody documents

Every tutorial stops at "enable mirrored networking". That isn't always enough: 127.0.0.1:9222 from WSL doesn't reliably reach the Windows Chrome. Every other time, connection refused for no visible reason — easy to blame the Windows firewall by mistake.

The missing link hides in the [experimental] block of .wslconfig (Windows side, C:\Users\<user>\.wslconfig):

[wsl2]
networkingMode=mirrored
[experimental]
hostAddressLoopback=true   # the key: bidirectional WSL <-> Windows loopback
ignoredPorts=9000

hostAddressLoopback=true makes loopback bidirectional between the Windows host and the WSL VM. Without it, mirrored covers 90% of cases but leaves gaps — and the debug port falls right into one. After editing .wslconfig, run wsl --shutdown on the Windows side to apply.

If you take one line away from this article, take that one.

Why --autoConnect doesn't work cross-OS

--autoConnect (Chrome 144+) promises to attach to your real, logged-in Chrome, no throwaway profile, via chrome://inspect/#remote-debugging. Except:

--autoConnect looks for a user-data-dir local to the MCP server's machine.

In WSL→Windows, the MCP server is on the Linux side, Chrome on the Windows side. The flag will never find the Windows profile from Linux. Dead on arrival cross-OS: --browserUrl remains the answer.

Another non-negotiable detail: the dedicated profile isn't optional.

--user-data-dir="C:\Temp\chrome-mcp"

If your personal Chrome is already open, running chrome.exe --remote-debugging-port=9222 without a distinct user-data-dir just opens a tab in the existing instance, without opening the debug port. You think it launched, and it didn't. A separate profile guarantees a new "debuggable" instance starts, leaving your tabs, cookies and extensions alone.

The insight: the MCP connection is lazy

The next goal: have Chrome start on its own at the right moment, with no manual launch per session. The question is when to trigger it. One piece of information changes everything:

The chrome-devtools-mcp server doesn't connect to the browser at startup. The connection is lazy: it happens on the first tool call that needs the browser.

Direct consequence: a PreToolUse hook matching mcp__chrome-devtools__.* fires right before that first call. It launches Chrome, waits for it to answer, and the MCP's lazy connection follows through.

The usual community approach puts a wrapper in place of the MCP's command instead. That works, but the wrapper runs at MCP server startup — i.e. on every Claude Code launch: a Chrome window popping on every session, even when you never touch the browser. The PreToolUse hook is lazy like the connection: Chrome only starts when it's needed.

The PreToolUse hook

The hook goes in ~/.claude/settings.json. Important point: it has to be a hook, not an instruction in CLAUDE.md or in memory. Those two are context (the agent "tries" to follow), not guaranteed execution. Automatic behaviour triggered by an event is a hook's job.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__chrome-devtools__.*",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/ensure-chrome-windows.sh",
            "timeout": 30,
            "statusMessage": "Launching Chrome (Windows) for MCP…"
          }
        ]
      }
    ]
  }
}

The mcp__chrome-devtools__.* matcher follows MCP tool naming (mcp__<server>__<tool>), so it catches every tool from that server.

The script must be idempotent: do nothing if Chrome already answers, launch it only if absent, and block the call (exit 2) with a clear message rather than letting the MCP fail cryptically. Plus one log line per run, whose value shows up right after.

~/.claude/hooks/ensure-chrome-windows.sh
#!/usr/bin/env bash
# Ensures a Windows-side Chrome exposes debug port 9222
# BEFORE any chrome-devtools MCP tool call (WSL -> Windows bridge).
# Idempotent. Traces to ~/.claude/chrome-hook.log (already-up | launched | FAIL).
set -u

PORT=9222
URL="http://127.0.0.1:${PORT}/json/version"
CHROME="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
LOG="$HOME/.claude/chrome-hook.log"
ts() { date '+%Y-%m-%d %H:%M:%S'; }

# Already up? nothing to do (most frequent case).
if curl -fsS "$URL" >/dev/null 2>&1; then
  echo "$(ts) already-up" >> "$LOG"
  exit 0
fi

if [ ! -f "$CHROME" ]; then
  echo "$(ts) FAIL chrome.exe not found ($CHROME)" >> "$LOG"
  echo "ensure-chrome-windows: chrome.exe not found ($CHROME)" >&2
  exit 2
fi

# Dedicated profile is MANDATORY: the debug port only opens in an
# instance with a distinct --user-data-dir.
mkdir -p /mnt/c/Temp/chrome-mcp 2>/dev/null
"$CHROME" \
  --remote-debugging-port="$PORT" \
  --remote-allow-origins='*' \
  --user-data-dir='C:\Temp\chrome-mcp' \
  --no-first-run --no-default-browser-check \
  >/dev/null 2>&1 &
disown

# Wait for the endpoint to answer (up to ~10s) so the MCP server's lazy
# connection succeeds when the tool call happens.
for i in $(seq 1 20); do
  if curl -fsS "$URL" >/dev/null 2>&1; then
    echo "$(ts) launched (~$((i*500))ms)" >> "$LOG"
    exit 0
  fi
  sleep 0.5
done

echo "$(ts) FAIL no response on :${PORT} after 10s" >> "$LOG"
echo "ensure-chrome-windows: Chrome didn't expose :${PORT} after 10s" >&2
exit 2

Don't forget chmod +x ~/.claude/hooks/ensure-chrome-windows.sh. And since hooks load at Claude Code startup, after editing settings.json you need to open /hooks once (which reloads the config) or restart.

Proof by log

Without a log, you can't tell "the hook did its no-op" from "the hook never fired, but Chrome happened to be there". Same result on screen.

With Chrome already open, the hook fires and short-circuits:

13:07:42 already-up
13:07:45 already-up
13:07:59 already-up
13:08:13 already-up

One firing per tool call: proof the hook is loaded and matching. Then the decisive test, Chrome closed before the session:

13:24:22 launched (~1000ms)
13:24:26 already-up
13:24:26 already-up

Chrome was absent, the hook started it in one second, and subsequent calls found it up. Without that log branch, you'd swear "it works" on the strength of a false positive.

Diagnosing common problems

  • curl http://127.0.0.1:9222/json/version doesn't answer → the hook should have relaunched Chrome; check it's active (/hooks) and test the script by hand.
  • list_pages returns a stale Chrome → if the MCP started with the old config (pipe), restart Claude Code so it reloads its args.
  • Host header validation error (VM→host connection rejected by Chrome) → that's exactly what --remote-allow-origins='*' is for. As a last resort, the official troubleshooting suggests an SSH tunnel from WSL:
ssh -N -L 127.0.0.1:9222:127.0.0.1:9222 <user>@<host-ip>

As long as mirroring works, that tunnel is unnecessary — but good to know about.

Long-term stability: the OOM trap

After a few days of heavy use, WSL can start crashing for no apparent reason. Diagnosis: a confirmed memory leak in chrome-devtools-mcp (issues #1192, #1214), made worse by each Claude Code session launching its own MCP instance. After 4-5 cumulative sessions the VM saturates, the OOM killer fires and takes out systemd/dbus. Reboot required.

Two guardrails together are enough.

Give WSL headroom. In .wslconfig, raise swap (8 GB by default is too tight) and don't enable autoMemoryReclaim=dropCache, which is too aggressive:

[wsl2]
swap=24GB
# [experimental]
# autoMemoryReclaim=gradual   # the gentle version — avoid dropCache

Cap each MCP instance via cgroup. In ~/.claude.json, wrap the command in systemd-run --user --scope:

"chrome-devtools": {
  "type": "stdio",
  "command": "bash",
  "args": [
    "-lc",
    "exec systemd-run --user --scope -p MemoryMax=6G -p MemorySwapMax=4G npx chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:9222 --acceptInsecureCerts"
  ]
}

If one instance leaks, the cgroup kills that one alone instead of triggering the global OOM that would take systemd down. That's the explicit recommendation from the upstream ticket.

Tricky detail: applying a new .wslconfig requires wsl --shutdown. If you then hit 0x8007054f (CreateInstance/CreateVm/ConfigureNetworking), it's known and transient — a Windows reboot fixes it (HNS/WinNat reset).

What works, what doesn't

Works:

  • Full page inspection (console, network, screenshots) on a real, interactive Windows Chrome
  • Automatic, lazy Chrome launch on the first tool call, zero manual step
  • Idempotence: no duplicate Chrome window, no pointless launch on sessions that never touch the browser
  • Human actions (OAuth, captcha, 2FA): you type straight into the Windows window while the MCP inspects in parallel

Doesn't work (or not that way):

  • --autoConnect cross-OS WSL→Windows (looks for a local profile that isn't there)
  • Launching Chrome without a dedicated --user-data-dir when the personal Chrome is already running (the debug port stays closed)
  • Relying on CLAUDE.md or memory for "every time" behaviour: you need a hook

Two lessons to keep: hostAddressLoopback=true, the networking prerequisite everyone forgets, and the lazy MCP connection, which makes the PreToolUse hook not just viable but better than the wrapper. Plus a general habit: when you want to be sure an automation fires, give it a log line. One second to write, and never again the doubt of a false positive.

ShareLinkedInXBluesky

Related articles