chrome-devtools-mcp is a Puppeteer-based MCP server that gives an agent direct control over a real Chrome instance — navigate, screenshot, inspect the DOM, read console/network output. On a fresh WSL2 setup it failed on every single call, immediately, with an error that gave no indication of what was actually wrong:

Protocol error (Target.setDiscoverTargets): Target closed

The environment: WSL2 (Ubuntu-based) on a Windows 11 host, WSLg enabled (GUI apps work fine via DISPLAY=:0/Wayland), chromium installed on the Linux side via apt (/usr/bin/chromium — not google-chrome), and a normal Google Chrome install on the Windows side. That specific combination — Linux Chromium from apt, Windows-side Chrome installed separately — turned out to be exactly the trigger condition.

Ruling out the obvious causes

The first hypothesis for any WSL2 GUI failure is usually “no X server” or “no browser installed.” Both were checked directly:

which google-chrome google-chrome-stable chromium chromium-browser
echo "DISPLAY=$DISPLAY"; echo "WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
ls -la /tmp/.X11-unix

chromium was installed, DISPLAY=:0 was set, the X11 socket existed. To confirm WSLg itself was fine, chromium was launched directly from the shell — both headless and headed — and it started cleanly both times, opening a working DevTools port with no errors. That ruled out WSL2/WSLg as a blanket blocker; whatever was wrong was specific to how the MCP server itself was invoking the browser.

The next hypothesis was a stuck MCP server process holding a dead browser handle, since it had been running the whole session. The process tree was killed outright and reconnected via /mcp reconnect chrome-devtools. A brand-new process, confirmed by fresh PIDs in ps aux, hit the identical Target closed error on its very first tool call, at 0 seconds elapsed. That ruled out stuck state — this was deterministic on a clean process, not a corrupted session.

Reproducing it outside the MCP harness

Claude Code logs MCP stdio traffic to disk under ~/.cache/claude-cli-nodejs/.../mcp-logs-chrome-devtools/*.jsonl. Reading it confirmed the timing precisely — new_page called, 0 seconds later the same protocol error came back — but not the why.

The actual npm-installed package source was next: ~/.npm/_npx/<hash>/node_modules/chrome-devtools-mcp/build/src/browser.js. This is the function the server calls to launch Chrome. It calls Puppeteer’s puppeteer.launch() with pipe: true (CDP over a file-descriptor pipe, not a WebSocket) and — critically — no explicit executablePath, meaning it falls back to Puppeteer’s own browser-channel auto-detection (channel: 'chrome').

A standalone script reproduced the failure in isolation, calling that exact function directly:

import { launch } from '.../chrome-devtools-mcp/build/src/browser.js';
try {
  const browser = await launch({ channel: undefined, executablePath: undefined, headless: false, isolated: false });
} catch (err) {
  console.error('LAUNCH FAILED:', err);
}

Same TargetCloseError, now reproducible in a handful of lines outside any MCP plumbing — much easier to bisect from here.

Isolating the variable

Two follow-up calls split the two remaining suspects — the pipe transport and the executable resolution — apart.

Pinning executablePath explicitly to the Linux binary worked immediately, real PID returned:

puppeteer.launch({
  executablePath: '/usr/bin/chromium',
  headless: false,
  pipe: true,
  userDataDir: '...',
});

That ruled out pipe: true itself as the problem. The second call used the MCP server’s actual default (channel: 'chrome'), plus Puppeteer’s dumpio: true option — which forwards the launched browser’s own stdout/stderr instead of letting it get silently swallowed, which is exactly what the MCP server does by default unless a log file is explicitly configured. That’s why every earlier symptom was just an opaque “target closed”: the one line that actually explained everything was never being surfaced.

[0828/195049.645:ERROR:chrome\app\chrome_main_delegate.cc:1101] Remote debugging pipe file descriptors are not open.

The tell: backslashes in a stack trace

chrome\app\chrome_main_delegate.cc — backslashes, not forward slashes. Linux and macOS Chromium builds compile with forward-slash source paths baked into their __FILE__ macros; a backslash path only shows up in a Windows-compiled Chrome binary. Somehow, on a Linux os.platform(), Puppeteer had resolved channel: 'chrome' to an actual Windows executable.

Grepping Puppeteer’s bundled source (chrome-devtools-mcp vendors its own copy under third_party/) for Windows-path logic turned up a function literally named getChromeLinuxOrWslLocation():

function getWslLocation(channel) {
  const wslVersion = execSync('wslinfo --version', { ... }).trim();
  if (!wslVersion) throw new Error('Not in WSL or unsupported version of WSL.');
  const wslPrefixes = new Set();
  for (const name of ['PROGRAMFILES', 'ProgramW6432', 'ProgramFiles(x86)', 'LOCALAPPDATA']) {
    const wslPrefix = getWslVariable(name); // execSync(`cmd.exe /c echo %${name}%`)
    if (wslPrefix) wslPrefixes.add(wslPrefix);
  }
  const windowsPath = getChromeWindowsLocation(channel, wslPrefixes); // C:\Program Files\Google\Chrome\Application\chrome.exe
  return windowsPath.map(path => execSync(`wslpath "${path}"`).toString().trim());
}
function getChromeLinuxOrWslLocation(channel) {
  const locations = [];
  if (channel === 'stable') locations.push('/opt/google/chrome/chrome'); // apt google-chrome-stable path
  try { locations.push(...getWslLocation(channel)); } catch { /* not in WSL, ignore */ }
  return locations;
}

This is deliberate, documented WSL-awareness in Puppeteer/@puppeteer/browsers, not a bug in the traditional sense. When it detects it’s running inside WSL (via wslinfo --version), it goes out of its way to also look for a Windows-side Chrome install, by shelling out to cmd.exe for Windows environment variables and converting the result through wslpath.

The candidate list for channel: 'chrome' on Linux/WSL is exactly two things: /opt/google/chrome/chrome (where the apt google-chrome-stable package installs to), and whatever wslpath resolves the Windows Chrome install to. Nothing in that list ever checks /usr/bin/chromium — that’s a different apt package (chromium, not google-chrome-stable), and Puppeteer’s “chrome” channel only ever looks for actual Google Chrome, never Chromium.

In this environment, candidate one didn’t exist (only chromium was installed via apt), so it failed silently — but candidate two succeeded, because Chrome genuinely was installed on the Windows side. Puppeteer resolved to C:\Program Files\Google\Chrome\Application\chrome.exe, translated the path through WSL interop, and launched it as if it were a normal Linux child process using --remote-debugging-pipe.

That’s the actual failure: a Windows .exe, launched through WSL interop, doesn’t support Linux pipe file-descriptor inheritance the way a native Linux binary does. Chrome starts, its first startup check notices the DevTools pipe file descriptors aren’t wired up, prints the error above, and exits — before Puppeteer’s client ever gets to send a real command. From the outside, all that’s visible is Target closed.

The fix

Force Puppeteer to skip channel/system-detection entirely by passing an explicit executablePath, which short-circuits the whole lookup — browser.js’s launch() returns immediately if executablePath is set, without ever touching the WSL-detection code path. In the MCP server’s config (~/.claude.json for Claude Code):

// before
{ "command": "npx", "args": ["-y", "chrome-devtools-mcp@latest"] }

// after
{ "command": "npx", "args": ["-y", "chrome-devtools-mcp@latest", "--executablePath=/usr/bin/chromium"] }

MCP server args are only read at spawn time, so the server had to be reconnected after the edit. Once reconnected, new_page and take_screenshot both worked immediately, and a real screenshot confirmed end-to-end rendering rather than just a successful navigation call.

Why this is worth knowing

The error message gives zero indication of the real cause — it looks identical whether the browser never launched, crashed on startup, or was killed mid-session, three very different problems with the same symptom. The MCP server swallows the launched browser’s stdout/stderr by default, which hides the one line that explains everything; Puppeteer’s dumpio: true is worth remembering as a general debugging trick for any Puppeteer-based tool, not just this one.

This will hit any Puppeteer-based tool running inside WSL2 that doesn’t pin an explicit executablePath, relies on the pipe: true CDP transport, and runs on a machine where the Windows side has Chrome installed but the Linux side only has chromium (or nothing at all) — a fairly common WSL2 dev setup, since plenty of people never bother installing a full Chrome/Chromium on the Linux side when Windows already has one. The WSL-Chrome-detection behavior is intentional upstream — it exists so system-executable-connection scenarios can find a working browser at all in WSL environments with nothing Linux-native installed — it’s just a poor fit for tools using the pipe transport, which needs a same-OS child process.

SymptomLooks likeActually is
Target.setDiscoverTargets: Target closed on every call, 0s elapsedStuck MCP server or OS can’t run GUI appsPuppeteer resolved channel: 'chrome' to Windows Chrome via WSL interop, which can’t use the pipe CDP transport
No chromium process ever appears in ps auxBrowser never even tried to launchIt launched as chrome.exe via interop, and exits almost instantly
Killing/restarting the MCP server doesn’t helpDeeper corruptionFresh processes hit the identical failure — deterministic, not stateful
Manually running chromium from the shell works fineContradicts “the browser is broken”Correct — the Linux chromium binary was never actually being used by the MCP server in the first place

The general lesson carries past this one tool: when a framework’s error message is opaque, reproduce the failing call in complete isolation, outside the framework, as early as possible. Going straight to the vendored source and calling the exact same function with the exact same arguments turned a completely opaque “Target closed” into a fully explained root cause in a handful of steps — each one ruling out exactly one hypothesis (OS/display, stuck process, transport mechanism, executable resolution) instead of guessing at the whole problem at once.