The chrome-devtools MCP server drives Chrome through the Chrome DevTools Protocol. From WSL, use a dedicated Windows Chrome profile and launch it with remote debugging enabled.
This works with MCP clients such as Codex and Claude Code.
Requirements
- WSL2 mirrored networking, so WSL should be able to reach Windows Chrome on
localhost. - A dedicated Chrome profile created with
--user-data-dir.
Remote debugging exposes browser state for that profile to local clients. Do not use your main Chrome profile for MCP sessions.
WSL Networking
Enable mirrored networking in %USERPROFILE%\.wslconfig:
Restart WSL:
After restart, WSL should be able to reach the Windows Chrome debugging endpoint through localhost.
Chrome Profile
Chrome 136+ blocks remote debugging on default profiles. Use a separate profile with --user-data-dir.
Use one profile per project directory. This keeps logins, cookies, and extensions isolated between projects, while still persisting auth across restarts.
Store profiles on the Windows filesystem, for example:
Helper Script
A literate helper script to launch, inspect, and stop directory-scoped debug Chrome instances from WSL:
#!/usr/bin/env bash
#
# chrome-debug - Launch or stop Chrome with remote debugging for DevTools MCP
#
# USAGE
# chrome-debug Start Chrome (auto-picks port from 9222)
# chrome-debug start Same as above
# chrome-debug start -p N Start on explicit port N
# chrome-debug stop Stop the debug Chrome for current repo
# chrome-debug port Print the port for current repo (for MCP config)
# chrome-debug status List all running debug sessions
#
# Run from WSL. Uses a dedicated profile at %LOCALAPPDATA%\ChromeDebugProfiles\<dir-name>
# to keep the debug browser separate from your main Chrome. Profile persists between sessions.
#
# Each repo/directory gets its own port. If port 9222 is already taken by another
# session, the next free port (9223, 9224, ...) is used automatically.
# State is tracked in ~/.cache/chrome-debug/<dir-name>.port
set -euo pipefail
CHROME_PATH='C:\Program Files\Google\Chrome\Application\chrome.exe'
BASE_PORT=9222
MAX_PORT=9230
STATE_DIR="$HOME/.cache/chrome-debug"
DIR_NAME=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")")
mkdir -p "$STATE_DIR"
# Read port from state file, or return empty
read_port() {
local port_file="$STATE_DIR/$DIR_NAME.port"
if [[ -f "$port_file" ]]; then
cat "$port_file"
fi
}
# Write port to state file
write_port() {
echo "$1" > "$STATE_DIR/$DIR_NAME.port"
}
# Remove state file
clear_port() {
rm -f "$STATE_DIR/$DIR_NAME.port"
}
# Check if a port has a Chrome debug instance listening (from WSL side)
port_in_use() {
local port=$1
# Try to connect to the Chrome DevTools JSON endpoint
curl -s --connect-timeout 1 "http://localhost:$port/json/version" >/dev/null 2>&1
}
# Find next free port starting from BASE_PORT
find_free_port() {
local port=$BASE_PORT
while (( port <= MAX_PORT )); do
if ! port_in_use "$port"; then
echo "$port"
return
fi
(( port++ ))
done
echo "Error: all ports $BASE_PORT-$MAX_PORT are in use." >&2
exit 1
}
pid_for_port() {
local port=$1
cmd.exe /C "netstat -a -n -o" 2>/dev/null |
tr -d '\r' |
awk -v port=":$port" '$1 == "TCP" && $2 ~ port "$" && $4 == "LISTENING" { print $5; exit }' || true
}
pid_matches_chrome_debug_endpoint() {
local pid=$1
local port=$2
local process_name
process_name=$(timeout 5s powershell.exe -NoProfile -NonInteractive -Command "
\$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
if (\$proc) { \$proc.ProcessName }
" | tr -d '\r\n' || true)
[[ "$process_name" == "chrome" ]] &&
curl -s --connect-timeout 1 "http://127.0.0.1:${port}/json/version" |
grep -q '"Browser": "Chrome/'
}
stop_debug_chrome() {
local port
port=$(read_port)
if [[ -z "$port" ]]; then
echo "No tracked session for '$DIR_NAME'."
return
fi
echo "Stopping Chrome debug instance (port $port, profile $DIR_NAME)..."
main_pid=$(pid_for_port "$port")
if [[ -z "$main_pid" ]]; then
echo "No Chrome process found on port $port (already stopped?)."
clear_port
return
fi
if ! pid_matches_chrome_debug_endpoint "$main_pid" "$port"; then
echo "Tracked port $port is not owned by a Chrome DevTools endpoint; refusing to stop PID $main_pid." >&2
clear_port
exit 1
fi
powershell.exe -NoProfile -NonInteractive -Command "Stop-Process -Id $main_pid -Force -ErrorAction SilentlyContinue"
# Mark exit as clean to prevent "restore pages" prompt
local_appdata=$(powershell.exe -NoProfile -NonInteractive -Command 'Write-Host $env:LOCALAPPDATA' | tr -d '\r\n' | sed 's|\\|/|g' | sed 's|^\([A-Za-z]\):|/mnt/\L\1|')
prefs_file="$local_appdata/ChromeDebugProfiles/$DIR_NAME/Default/Preferences"
if [[ -f "$prefs_file" ]]; then
sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/g' "$prefs_file"
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/g' "$prefs_file"
fi
clear_port
echo "Debug Chrome stopped."
}
start_debug_chrome() {
local port
# Check if this repo already has a running session
local existing_port
existing_port=$(read_port)
if [[ -n "$existing_port" ]] && port_in_use "$existing_port"; then
echo "Already running on port $existing_port (profile $DIR_NAME)."
return
fi
# Explicit port via -p flag, or auto-pick
if [[ -n "${1:-}" ]]; then
port=$1
if port_in_use "$port"; then
echo "Port $port is already in use by another session."
echo "Use 'chrome-debug status' to see all sessions."
exit 1
fi
else
port=$(find_free_port)
fi
echo "Starting Chrome with remote debugging on port $port (profile $DIR_NAME)..."
# The profile path is persistent, so browser auth state survives restarts.
# First-run prompts are disabled because they block automated DevTools use.
# Chrome 111+ rejects remote WebSocket clients unless their origin is allowed.
powershell.exe -NoProfile -NonInteractive -Command "
\$profileDir = \"\$env:LOCALAPPDATA\\ChromeDebugProfiles\\$DIR_NAME\"
\$chromeArgs = @(
'--remote-debugging-port=$port',
\"--user-data-dir=\$profileDir\",
'--disable-session-crashed-bubble',
'--no-first-run',
'--no-default-browser-check',
'--disable-search-engine-choice-screen',
'--remote-allow-origins=*'
)
Write-Host \"Profile: \$profileDir\"
Start-Process -FilePath '$CHROME_PATH' -ArgumentList \$chromeArgs
"
write_port "$port"
echo "Port $port saved to $STATE_DIR/$DIR_NAME.port"
}
show_port() {
local port
port=$(read_port)
if [[ -z "$port" ]]; then
echo "No tracked session for '$DIR_NAME'." >&2
exit 1
fi
if port_in_use "$port"; then
echo "$port"
else
echo "Port $port was tracked but Chrome is not responding. Cleaning up." >&2
clear_port
exit 1
fi
}
show_status() {
local found=0
for port_file in "$STATE_DIR"/*.port; do
[[ -f "$port_file" ]] || continue
local name port status
name=$(basename "$port_file" .port)
port=$(cat "$port_file")
if port_in_use "$port"; then
status="running"
else
status="stale (cleaning up)"
rm -f "$port_file"
fi
printf "%-30s port %-5s %s\n" "$name" "$port" "$status"
found=1
done
if (( found == 0 )); then
echo "No debug sessions tracked."
fi
}
# Parse command and flags
cmd="${1:-start}"
shift || true
case "$cmd" in
start|"")
explicit_port=""
while (( $# > 0 )); do
case "$1" in
-p) explicit_port="${2:?'-p requires a port number'}"; shift 2 ;;
*) echo "Unknown flag: $1"; exit 1 ;;
esac
done
start_debug_chrome "$explicit_port"
;;
stop) stop_debug_chrome ;;
port) show_port ;;
status) show_status ;;
*) echo "Usage: $0 [start [-p PORT]|stop|port|status]"; exit 1 ;;
esacMCP Client Wrapper
Do not hardcode the debug port in MCP client configuration. chrome-debug can choose 9222, 9223, or another port if earlier sessions already use lower ports.
Install a wrapper script named chrome-devtools-mcp somewhere in your PATH:
#!/usr/bin/env bash
set -euo pipefail
chrome_debug_responds() {
local port=$1
curl -s --connect-timeout 1 "http://127.0.0.1:${port}/json/version" >/dev/null 2>&1
}
wait_for_chrome_debug() {
local port=$1
local tries=0
while (( tries < 50 )); do
if chrome_debug_responds "$port"; then
return 0
fi
sleep 0.2
(( tries++ ))
done
return 1
}
noop_mcp_server() {
exec node -e '
const input = process.stdin;
let buffer = Buffer.alloc(0);
let transport = "content-length";
function send(message) {
const body = Buffer.from(JSON.stringify(message), "utf8");
if (transport === "newline") {
process.stdout.write(`${body.toString("utf8")}\n`);
return;
}
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
process.stdout.write(body);
}
function handle(message) {
if (!message || message.id === undefined) {
return;
}
if (message.method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion || "2025-06-18",
capabilities: { tools: {} },
serverInfo: {
name: "chrome-devtools-mcp-wrapper",
version: "0.1.0"
}
}
});
return;
}
if (message.method === "tools/list") {
send({ jsonrpc: "2.0", id: message.id, result: { tools: [] } });
return;
}
if (message.method === "resources/list") {
send({ jsonrpc: "2.0", id: message.id, result: { resources: [] } });
return;
}
if (message.method === "prompts/list") {
send({ jsonrpc: "2.0", id: message.id, result: { prompts: [] } });
return;
}
send({
jsonrpc: "2.0",
id: message.id,
error: { code: -32601, message: "Chrome DevTools is not running. Start chrome-debug before Codex to enable these MCP tools." }
});
}
input.on("data", chunk => {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
let headerEnd = buffer.indexOf("\r\n\r\n");
let separatorLength = 4;
if (headerEnd === -1) {
headerEnd = buffer.indexOf("\n\n");
separatorLength = 2;
}
if (headerEnd === -1) {
const newlineEnd = buffer.indexOf("\n");
if (newlineEnd === -1) {
return;
}
transport = "newline";
const line = buffer.subarray(0, newlineEnd).toString("utf8").trim();
buffer = buffer.subarray(newlineEnd + 1);
if (line) {
handle(JSON.parse(line));
}
continue;
}
const header = buffer.subarray(0, headerEnd).toString("utf8");
const lengthMatch = header.match(/content-length:\s*(\d+)/i);
if (!lengthMatch) {
process.exit(1);
}
const length = Number(lengthMatch[1]);
const messageStart = headerEnd + separatorLength;
const messageEnd = messageStart + length;
if (buffer.length < messageEnd) {
return;
}
const body = buffer.subarray(messageStart, messageEnd).toString("utf8");
buffer = buffer.subarray(messageEnd);
handle(JSON.parse(body));
}
});
'
}
port="$(chrome-debug port 2>/dev/null || true)"
if [[ -z "${port:-}" ]]; then
noop_mcp_server
fi
if ! wait_for_chrome_debug "$port"; then
noop_mcp_server
fi
if [[ -n "${CHROME_DEVTOOLS_MCP_BIN:-}" ]]; then
exec "$CHROME_DEVTOOLS_MCP_BIN" --browserUrl "http://127.0.0.1:${port}"
fi
exec npx --yes chrome-devtools-mcp@0.25.0 --browserUrl "http://127.0.0.1:${port}"Then point the MCP client at the wrapper. For Codex:
You can start Chrome from the target repository before using the MCP tools:
The wrapper asks chrome-debug port for the current repository. If that command cannot return a live tracked port, the wrapper starts a minimal no-tool MCP server so Codex does not show a startup warning. Start chrome-debug explicitly from the target repository before starting Codex when you need the DevTools MCP tools. The MCP wrapper should attach to Chrome only; it should not open Chrome as a side effect of starting Codex, and it should not guess by attaching to another untracked Chrome DevTools endpoint.
Do not use npx --yes chrome-devtools-mcp@latest in the wrapper. Use a pinned package version so the Chrome-enabled path is reproducible. The wrapper only calls npx after it has found a live tracked chrome-debug port, so normal Codex startup without Chrome does not wait on npm.