
chrome-devtools MCP from WSL: driving (and auto-launching) a Windows Chrome
The problem: chrome-devtools MCP in WSL wants a Linux Chrome
I live in WSL2 under Windows 11. Everything I build runs in there: Claude Code, my dev servers, my local HTTPS domains served by Caddy. So of course I wanted to wire up the chrome-devtools MCP and let the agent inspect my pages on its own. Console, network requests, screenshots, without leaving the terminal. The dream.
Except the default mode connects over --remote-debugging-pipe and launches its own Chrome inside the Linux environment. Under WSLg, you get a near-dead Chrome window: it shows up, but the keyboard won't follow. I found that out the worst way, one evening, staring at a Google login screen, unable to type the password. Stuck. The moment a human action shows up (login, captcha, 2FA), it's game over.
What I wanted was the exact opposite: drive my real Windows Chrome, the one I type in normally, and let the MCP inspect it in parallel.
The official fix: aim at the Windows Chrome
Since I run networkingMode=mirrored, Windows and WSL share localhost. The idea fits in two lines:
- Launch a Chrome on the Windows side with the debug port open.
- Tell the MCP to hook into it 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: thanks to it, I load my self-signed local domains (*.dev.fransys.io behind Caddy) without Chrome rejecting the cert.
And then, a nice surprise while digging: this is word for word the WSL method the official chrome-devtools-mcp README now documents (the package went 1.x in May 2026). Mirrored networking, chrome.exe --remote-debugging-port=9222, --browser-url http://127.0.0.1:9222. What I took for a personal hack turns out to be the official path. Reassuring.
The networking prerequisite nobody documents
This is where I burned the most time. Every tutorial stops at "enable mirrored networking." On my machine, not enough: 127.0.0.1:9222 from WSL didn't always reach the Windows Chrome. One time in two, connection refused, no obvious reason. A solid hour wasted blaming the Windows firewall before I realized the problem was somewhere else entirely.
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 loopback WSL <-> Windows
ignoredPorts=9000
hostAddressLoopback=true is what makes the loopback bidirectional between the Windows host and the WSL VM. Without it, mirrored covers 90% of cases but leaves holes, and the debug port falls right into one. After editing .wslconfig, a wsl --shutdown on the Windows side (then restart WSL) to apply.
If you remember one line from this article, take that one.
Why --autoConnect doesn't work cross-OS
Reading the release notes, I ran into --autoConnect (Chrome 144+). On paper, the dream: hook into your real logged-in Chrome, no throwaway profile, via chrome://inspect/#remote-debugging. I jumped on it. Then I read the small print:
--autoConnectlooks for auser-data-dirlocal to the MCP server's machine.
But 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. So for this setup, --browserUrl stays the right call. It's exactly the kind of feature that shines on a single OS and faceplants the moment you cross the WSL boundary.
One more detail, confirmed by the community and that I validated the hard way: the dedicated profile isn't optional.
--user-data-dir="C:\Temp\chrome-mcp"
If your personal Chrome is already open, relaunching 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's running, and it isn't. The separate profile guarantees a fresh "debuggable" instance starts, without touching your personal tabs, cookies and extensions.
The insight: the MCP connection is lazy
I could've stopped at launching Chrome by hand every session. But no. I wanted it to happen on its own: every time I ask the agent to check a page via MCP chrome, the Windows Chrome should start if needed.
That leaves the question of when to trigger the launch. And here, one fact flips 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 until it responds, and the MCP's lazy connection picks up without a hitch. The timing lands.
The community tends to drop a wrapper in place of the MCP's command instead. It works, but that wrapper runs at MCP server startup, i.e. on every Claude Code launch. So a Chrome window popping up every session, even when I never touch the browser. No thanks. The PreToolUse hook is lazy like the connection: Chrome only starts when I actually need it.
Automating it: the PreToolUse hook
The hook goes in ~/.claude/settings.json. Key point: it has to be a hook, not an instruction in CLAUDE.md or memory. Those two are context (the agent "tries" to follow them), not guaranteed execution. An automatic behavior fired by an event is a hook's job, full stop.
{
"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 the MCP tool naming (mcp__<server>__<tool>), so it catches every tool of the chrome-devtools server.
The idempotent script (with a proof log)
The script has to be idempotent: do nothing if Chrome already responds, launch it only if absent, and block the call (exit 2) with a clear message instead of letting the MCP fail like a riddle. I also bolted on one log line per run. We'll see in a second why that's the actually interesting bit.
#!/usr/bin/env bash
# Ensures a Windows-side Chrome exposes the debug port 9222
# BEFORE any chrome-devtools MCP tool call (WSL -> Windows bridge).
# Idempotent. Logs 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 (the 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 respond (up to ~10s) so the MCP server's
# lazy connection succeeds at tool-call time.
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 the executable bit: chmod +x ~/.claude/hooks/ensure-chrome-windows.sh. And since hooks load at Claude Code startup, after editing settings.json you have to open /hooks once (that reloads the config) or restart.
Proof by the log
This is where the log line pays off. For a good while I couldn't tell "the hook did its no-op" apart from "the hook didn't fire, but Chrome happened to be there." Same thing on screen. Annoying.
The log settles it. First, sessions 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
Six firings, one per chrome-devtools tool call. Proof the hook is loaded and matching. Then the decisive test: I close Chrome before starting the session, and ask for a page check again:
13:24:22 launched (~1000ms)
13:24:26 already-up
13:24:26 already-up
launched (~1000ms): Chrome was gone, the hook started it on its own in one second, and the following calls found it up. Full scenario, validated end to end. Without that log branch, I'd have sworn "it works" on the strength of a false positive. The lesson goes well beyond Chrome.
Troubleshooting
Three traps I hit along the way, and how to diagnose them:
curl http://127.0.0.1:9222/json/versiondoesn't respond → the hook should have relaunched Chrome; check it's active (/hooks) and test the script by hand.list_pagesreturns an old 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 offers 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, this tunnel is useless. But it's comforting to know it's there.
Long-term stability: the OOM trap
It all works. Except after a few days of heavy use, WSL starts crashing for no obvious reason. Diagnosis: a confirmed memory leak bug in chrome-devtools-mcp (issues #1192, #1214), made worse by the fact that each Claude Code session spawns its own MCP instance. After 4-5 sessions pile up, the VM saturates, the Linux kernel pulls the OOM killer, and it nukes systemd/dbus. WSL goes sideways. Reboot required.
Two safeguards, together, are enough.
Give WSL some headroom. In .wslconfig, bump the swap (8 GB by default is too tight) and don't enable autoMemoryReclaim=dropCache (too aggressive, destabilizes the VM):
[wsl2]
swap=24GB
# [experimental]
# autoMemoryReclaim=gradual # the soft variant — 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 only that one instead of triggering the global OOM that would take systemd down. It's the explicit recommendation from the upstream ticket.
Gotcha: 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). The error never survives a restart.
What works, what doesn't
It works:
- Full page inspection (console, network, screenshots) on a real, interactive Windows Chrome
- Automatic, lazy Chrome launch on the first tool call, zero manual steps
- Idempotence: no duplicate Chrome window, no pointless launch on sessions without a browser
- Human actions (OAuth, captcha, 2FA): I type straight into the Windows window while the MCP inspects in parallel
It doesn't (or not like that):
--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 shut) - Relying on
CLAUDE.mdor memory for an "every time" behavior: you need a hook
That's it
It started as just wanting to debug my pages from WSL. It ended as something that fires on its own at the right moment and that I don't even think about anymore. The two lessons I'm keeping: hostAddressLoopback=true (the networking prerequisite everyone forgets) and the lazy MCP connection (which makes the PreToolUse hook not just viable, but flat-out better than the wrapper).
And above all, this habit picked up along the way: when you want to be sure an automation fires, slap a log line on it. One second to write, and never again the doubt of a false positive.
Related articles