
chrome-devtools MCP from WSL: driving (and auto-launching) a Windows Chrome
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:
--autoConnectlooks for auser-data-dirlocal 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-mcpserver 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.
#!/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/versiondoesn't answer → the hook should have relaunched Chrome; check it's active (/hooks) and test the script by hand.list_pagesreturns a stale Chrome → if the MCP started with the old config (pipe), restart Claude Code so it reloads its args.Hostheader 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):
--autoConnectcross-OS WSL→Windows (looks for a local profile that isn't there)- Launching Chrome without a dedicated
--user-data-dirwhen the personal Chrome is already running (the debug port stays closed) - Relying on
CLAUDE.mdor 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.
Related articles