TL;DR
Setup: Claude Code (or Opencode) running in WSL2 + MCP Chrome DevTools server + Chrome running on the Windows host with
--remote-debugging-port=9222and a dedicated--user-data-dir. Running Claude Code natively on Windows? This problem doesn't exist for you — stop reading.Symptom: MCP can't reach Chrome's debug port from inside WSL. Connection refused. Or it connects, lists targets, then every navigation hangs.
Cause, in one sentence: Chrome can bind
--remote-debugging-portto either127.0.0.1or[::1]depending on build, profile, and platform state, and WSL2's default NAT networking forwards yournetsh portproxyrule to whichever address family you typed — if that doesn't match what Chrome actually bound, you get connection refused.Fix:
netstat -ano | findstr :9222on Windows first. If it shows127.0.0.1:9222, use av4tov4rule. If it shows[::1]:9222, use av4tov6rule. Same command, one character different.
# If Chrome bound [::1]:9222 — this is the one most v4→v4 tutorials miss
netsh interface portproxy delete v4tov4 listenport=9222 listenaddress=0.0.0.0
netsh interface portproxy add v4tov6 listenport=9222 listenaddress=0.0.0.0 connectport=9222 connectaddress=::1
netsh interface portproxy show all
Important security note: Since Chrome 136, the browser refuses
--remote-debugging-portagainst your default user data dir on purpose. You'll need a separate--user-data-dirand you'll need to sign into the accounts you want the agent to operate on, in that profile. There is no longer a path that exposes your real, signed-in default Chrome to CDP — and you wouldn't want one.
I use Claude Code — and Opencode running Qwen3.6 — to drive Chrome. UI debugging on apps I'm building, filling out job applications, working contract pitches, anything where the agent has to operate as me on a real signed-in page. Not headless Playwright. An actual GUI Chrome on my Windows host, with the accounts I sign into. The agent lives in WSL2.
A note up front about what I'm not using. Anthropic ships a "Claude for Chrome" browser extension that does roughly the same thing — let an agent see and act on the page you're on. I don't run it. A browser extension with page-read + page-act scope sits inside the same security boundary as my password manager, my bank tabs, my email. The extension permission model has a long history of scopes drifting wider over time, and I'd rather not stake my logged-in session on a single vendor's promise that it won't. MCP over the DevTools Protocol keeps the agent outside Chrome, talking to it through a socket I control — one I can netstat and firewall. Both designs have failure modes; the failure modes I can audit are the ones I pick.
That brings up a related thing worth saying up front. Since Chrome 136 Chromium refuses --remote-debugging-port (and --remote-debugging-pipe) when you point it at your default user data directory. The change was a direct response to malware-as-extension actors driving real-user Chrome sessions over CDP to lift cookies and bypass auth. So you can't actually wire MCP into the Chrome you use every day. You launch a second, dedicated Chrome profile with --user-data-dir="C:\chrome-debug" (or anywhere outside the default), and you sign in there to whichever accounts you want the agent to work with. "My real browser" is a slight overclaim — it's a real Chrome on my machine, with my chosen signed-in sessions in a separate profile. That's the correct security boundary; I'm not trying to undo it.
The bridge that makes the MCP path work is the MCP Chrome DevTools server. It talks to Chrome over the DevTools Protocol — a WebSocket on http://localhost:9222/json/version. Easy when the agent and the browser live on the same machine. WSL2 is not that. WSL2 is a Hyper-V VM with its own network stack. localhost inside WSL is not localhost on Windows; from inside WSL the Windows host lives at the gateway address from ip route show default.
Quick aside, then we get to the fix. If you run Claude Code (or any MCP client) natively on Windows — in PowerShell, in cmd, in Git Bash, anything that isn't WSL — none of this applies. The agent and Chrome both live on the same OS; loopback is loopback. No portproxy, no firewall rule, no two-stack translation. This post is for the people running their dev environment in WSL2 because that's where the rest of their tooling lives. If that's not you, stop reading.
So you reach for the standard answer: netsh interface portproxy. I asked Claude Code how to wire it up. It gave me the v4tov4 form — the same one Microsoft's netsh portproxy docs lead with. I pasted it. Connection refused. The proxy was forwarding to an address family Chrome wasn't listening on.
Here's what's actually going on.
What Chrome Actually Binds To (Check Before You Pick A Rule)
Launch Chrome on Windows with a dedicated profile:
chrome.exe --remote-debugging-port=9222 --user-data-dir="C:\chrome-debug"
Then check what's listening:
netstat -ano | findstr :9222
You'll see one of two things:
TCP 127.0.0.1:9222 0.0.0.0:0 LISTENING 18244
or
TCP [::1]:9222 [::]:0 LISTENING 18244
Chromium's RemoteDebuggingServer tries 127.0.0.1 first and falls back to [::1] if it can't bind v4. Which one you get on any given launch is a function of your Chrome build, your Windows network stack, occasionally what else is on port 9222, and (yes, observed in the wild) which profile you're using. There is no published version cutoff. You have to look.
This is the single most common reason netsh portproxy v4tov4 "doesn't work" for WSL → Windows Chrome CDP. The Microsoft docs lead with v4→v4, the AI tools trained on those docs lead with v4→v4, and v4→v4 is right when Chrome bound 127.0.0.1. When Chrome bound [::1], it forwards your WSL traffic to an address family nothing is listening on, and you get connection refused.
You might reach for --remote-debugging-address=127.0.0.1 here and try to force v4. There's a flag with that name. Don't bother. In headful Chrome (which is the whole point of the "drive a real Chrome" workflow) the address can't be selected from the command line. Chromium removed remaining flag support in old headless in 2024. The fix has to live on the Windows networking side.
A Word On Mirrored Networking
Before the portproxy fix: on Windows 11 22H2+ there's a "right" architecture for this. WSL2 supports a networkingMode=mirrored setting in .wslconfig that lets Linux reach Windows-bound services via 127.0.0.1. ( Microsoft Learn) It only really helps you if Chrome bound v4 — Microsoft's docs are explicit that IPv6 loopback (::1) is not mirrored. If Chrome bound [::1], mirrored mode does not save you, and you're back to the portproxy fix below.
I've tried mirrored mode. I keep going back to portproxy. DNS quirks, Docker Desktop interactions, intermittent loopback issues that WSL issue #10803 tracks. Your mileage may vary; on paper it's a cleaner architecture for the v4 case. In practice the portproxy rule is two lines, survives reboots, and works against either address family as long as I pick the right one.
Move on.
The Fix (Pick The Rule That Matches Your netstat)
netsh interface portproxy supports four address-family combinations: v4tov4, v4tov6, v6tov4, v6tov6. WSL's NAT side talks v4 to the Windows host. So you're choosing between v4→v4 (Chrome bound 127.0.0.1) and v4→v6 (Chrome bound [::1]).
Open PowerShell as Administrator. Delete any stale rule on the port first, then add the one that matches what you saw in netstat:
# Always: clear whatever's there
netsh interface portproxy delete v4tov4 listenport=9222 listenaddress=0.0.0.0 2>$null
netsh interface portproxy delete v4tov6 listenport=9222 listenaddress=0.0.0.0 2>$null
If Chrome bound 127.0.0.1:9222:
netsh interface portproxy add v4tov4 listenport=9222 listenaddress=0.0.0.0 connectport=9222 connectaddress=127.0.0.1
If Chrome bound [::1]:9222:
netsh interface portproxy add v4tov6 listenport=9222 listenaddress=0.0.0.0 connectport=9222 connectaddress=::1
Confirm:
netsh interface portproxy show all
You should see one rule, on port 9222, pointing at the address family Chrome actually bound. Security note on listenaddress=0.0.0.0: that listens on every Windows network interface, not just the WSL virtual switch. On a laptop you carry to coffee shops that's a bigger exposure than you want, because the DevTools Protocol is unauthenticated full browser control. Scope it tighter if you're not on a trusted network — see the firewall section below.
Now from WSL:
curl http://$(ip route show default | awk '{print $3}'):9222/json/version
The ip route show default | awk '{print $3}' part is how you reach the Windows host from inside default-NAT WSL — localhost inside WSL points at the Linux VM, not at Windows. If you get Chrome's version JSON back, MCP will connect.
Finding The Right Addresses From Inside WSL
Two addresses matter when you wire this up. People mix them up; the firewall rule needs both.
The Windows host address (what MCP connects to). From inside default-NAT WSL, the Windows host lives at the gateway in your routing table. Ranked by reliability:
# 1. Best — gateway from the routing table. Works on every default-NAT WSL2 setup.
ip route show default | awk '{print $3}'
# 2. The DNS nameserver — usually the same address as the gateway on default WSL DNS,
# but not guaranteed if you've replaced /etc/resolv.conf with your own DNS.
grep nameserver /etc/resolv.conf | awk '{print $2}'
# 3. host.docker.internal — only if Docker Desktop installed it in /etc/hosts.
# Not a WSL guarantee; don't rely on it without Docker Desktop.
getent hosts host.docker.internal | awk '{print $1}'
Use it in scripts and your MCP config:
WSL_HOST=$(ip route show default | awk '{print $3}')
curl http://$WSL_HOST:9222/json/version
Note: under mirrored networking (Win11 22H2+ with networkingMode=mirrored) you skip this whole lookup and Linux reaches Windows-bound v4 services via 127.0.0.1 directly. Mirrored does not extend to ::1, so if Chrome bound v6 you're back to the gateway-IP path anyway.
Your WSL distro's address (what Windows sees as the source). This is the one you need for the firewall RemoteAddress scoping rule below. From inside WSL:
# Your eth0 IP — what shows up on Windows as the connecting client.
ip addr show eth0 | grep 'inet ' | awk '{print $2}' | cut -d/ -f1
Sample: 172.21.42.5. That's the address Windows Defender sees when WSL talks to it. For firewall scoping, you can either use that exact address with a /32 mask or — to survive WSL2's habit of re-randomizing the subnet across reboots — use 172.16.0.0/12, the IANA private range that covers every default-NAT WSL gateway I've ever seen.
Why This Bites Specifically With MCP
A normal browser-automation tool — Playwright, Puppeteer, Selenium — usually launches its own Chrome instance and controls the bind itself. It doesn't need a port proxy at all.
MCP Chrome DevTools is different by design. The point is that you connect to a Chrome of your choosing. So you launch Chrome yourself, on Windows, with a dedicated profile and --remote-debugging-port=9222. Then MCP connects from WSL across the network boundary. That's the architecture that walks into the address-family mismatch. Playwright's WSL guide doesn't mention it because Playwright never sees it. MCP does.
The Other Two Gotchas That Show Up Right After
You're not done. Two more land within five minutes.
1. Windows Defender Firewall, scoped tight. Even with the proxy rule, Defender may block inbound on port 9222. The naive fix is a permissive inbound allow. Don't write that one. The DevTools Protocol is unauthenticated full browser control — anyone who can reach 0.0.0.0:9222 on your machine can read your tabs, drive clicks, exfiltrate cookies. Scope the rule to the WSL Hyper-V virtual switch interface only, and (if you can) to loopback-equivalent remote addresses only:
# Find the WSL vEthernet interface index
Get-NetAdapter | Where-Object Name -like "*WSL*"
# Allow inbound only on that interface, only from the WSL subnet
New-NetFirewallRule `
-DisplayName "WSL MCP DevTools 9222 (WSL switch only)" `
-Direction Inbound `
-LocalPort 9222 `
-Protocol TCP `
-Action Allow `
-InterfaceAlias "vEthernet (WSL)" `
-RemoteAddress 172.16.0.0/12
Adjust the InterfaceAlias and RemoteAddress to match your actual WSL switch and the subnet your distro is on ( ip addr show eth0 inside WSL). The point is: do not leave port 9222 reachable from the LAN.
2. WSL host IP isn't always stable. Under default NAT networking, WSL2's gateway address can change between reboots and version updates. Hardcoding 172.21.x.x in your MCP config will eventually bite you. Resolve it dynamically:
WSL_HOST=$(ip route show default | awk '{print $3}')
echo "$WSL_HOST"
There's also a host.docker.internal hostname some setups expose — but that's a Docker Desktop convention that gets injected into /etc/hosts, not a WSL guarantee. If you don't have Docker Desktop installed it won't resolve. Stick with ip route show default for the WSL-NAT case.
Sanity Check Before You Blame MCP
If MCP still won't connect, the layers to check, in order:
Is Chrome actually running with
--remote-debugging-port=9222and a non-default--user-data-dir?Get-Process chrome | Select-Object Id, MainWindowTitle, Path— and confirm the launch command. A second Chrome instance from your Start Menu doesn't have the flag and, since 136, would have refused it anyway.What did Chrome bind?
netstat -ano | findstr :9222. If you see127.0.0.1:9222you needv4tov4. If you see[::1]:9222you needv4tov6. The rest of the post depends on this single check.Is the portproxy rule active and pointed at the same address family?
netsh interface portproxy show all.Is Defender allowing inbound 9222 on the WSL interface? Temporarily disable the inbound rule to test, then re-enable a scoped one.
Can WSL reach the gateway at all?
curl -v http://$(ip route show default | awk '{print $3}'):9222/json/version. The-vmatters; it tells you whether you got TCP, TLS, or HTTP-layer failure.
Stop at the first layer that fails. Don't change two things at once.
Why I'm Writing This Down
The MCP server logs said "connection refused." Chrome looked healthy. Claude Code kept handing me the v4tov4 form because that's what Microsoft's docs lead with, and that's what its training data weighted. Then it kept handing me v4tov6 once I described the symptom, which was right for that launch of Chrome but not the next one, where Chrome bound v4 again. The actual lesson isn't "use v4→v6." It's "look at what Chrome bound, pick the matching rule, and don't trust either the docs or the model to know which one applies on your box right now."
If you're trying to drive a Chrome on your Windows host from Claude Code (or Opencode, or any other MCP-speaking agent) inside WSL2, and the bridge won't connect — netstat -ano | findstr :9222 first, pick the matching netsh portproxy rule second. Move on with your day.