Skip to content

Server process does not exit on stdin EOF after a page has been opened #2116

@dballesteros7

Description

@dballesteros7

Description of the bug

chrome-devtools-mcp does not exit when its stdin is closed if a navigate_page (or any tool that opens a Chrome instance) has been issued during the session. The MCP protocol does not define a shutdown RPC and convention across stdio-based MCP servers is to exit on stdin EOF; without that, callers that cleanly close stdin have to fall back to SIGTERM / SIGKILL on every session that touched the browser.

Sessions that never opened a page (e.g. just tools/list) do exit on stdin EOF, in ~30 ms — so the transport's read loop terminates correctly. The issue is that opening Chrome adds a ref to the Node event loop and no shutdown handler closes it.

Reproduction

Minimal Node script + setup, with no MCP client involved:

mkdir cdmcp-repro && cd cdmcp-repro
npm init -y
npm i chrome-devtools-mcp
# save repro.mjs (see below)
node repro.mjs

repro.mjs:

// Spawn chrome-devtools-mcp, do the MCP handshake, run a tool call,
// close stdin, and time how long until the child exits (5s SIGTERM
// fallback so the script always ends).
import { spawn } from "node:child_process"
import { performance } from "node:perf_hooks"
import { fileURLToPath } from "node:url"
import path from "node:path"

const here = path.dirname(fileURLToPath(import.meta.url))
const BIN = path.join(here, "node_modules", ".bin", "chrome-devtools-mcp")
const KILL_AFTER_MS = 5000

function send(child, msg) {
  child.stdin.write(JSON.stringify(msg) + "\n")
}

function drive(requests, label) {
  return new Promise((resolve) => {
    const child = spawn(BIN, [], { stdio: ["pipe", "pipe", "pipe"] })
    const responses = new Map()
    let buf = ""
    child.stdout.on("data", (b) => {
      buf += b.toString()
      let nl
      while ((nl = buf.indexOf("\n")) >= 0) {
        const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
        if (!line.trim()) continue
        try { const p = JSON.parse(line); if (p.id !== undefined) responses.set(p.id, p) } catch {}
      }
    })
    child.stderr.on("data", () => {})
    send(child, { jsonrpc: "2.0", id: 1, method: "initialize",
      params: { protocolVersion: "2024-11-05", capabilities: {},
        clientInfo: { name: "repro", version: "0.0.0" } } })
    child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n")
    const tick = setInterval(() => {
      if (!responses.has(1)) return
      clearInterval(tick)
      for (const r of requests) send(child, { jsonrpc: "2.0", ...r })
      const tick2 = setInterval(() => {
        if (!requests.every((r) => responses.has(r.id))) return
        clearInterval(tick2)
        const t0 = performance.now()
        child.stdin.end()
        let killed = false
        const kill = setTimeout(() => { killed = true; child.kill("SIGTERM") }, KILL_AFTER_MS)
        child.on("exit", () => {
          clearTimeout(kill)
          console.log(`${label}: exit ${(performance.now() - t0).toFixed(0)}ms ${killed ? "(SIGTERM)" : "(clean)"}`)
          resolve()
        })
      }, 50)
    }, 50)
  })
}

await drive([{ id: 2, method: "tools/list" }], "tools/list only      ")
await drive(
  [{ id: 2, method: "tools/call", params: { name: "navigate_page", arguments: { url: "https://example.com" } } }],
  "navigate example.com ",
)

Observed

Across 10 invocations (40 total scenarios; clean Linux box, no other tabs, no MCP client):

iter 1:  tools/list only      : exit 30ms (clean)  | navigate example.com : exit 5078ms (SIGTERM)
iter 2:  tools/list only      : exit 36ms (clean)  | navigate example.com : exit 5081ms (SIGTERM)
iter 3:  tools/list only      : exit 35ms (clean)  | navigate example.com : exit 5085ms (SIGTERM)
iter 4:  tools/list only      : exit 34ms (clean)  | navigate example.com : exit 5097ms (SIGTERM)
iter 5:  tools/list only      : exit 36ms (clean)  | navigate example.com : exit 5100ms (SIGTERM)
iter 6:  tools/list only      : exit 33ms (clean)  | navigate example.com : exit 5084ms (SIGTERM)
iter 7:  tools/list only      : exit 33ms (clean)  | navigate example.com : exit 5078ms (SIGTERM)
iter 8:  tools/list only      : exit 37ms (clean)  | navigate example.com : exit 5077ms (SIGTERM)
iter 9:  tools/list only      : exit 30ms (clean)  | navigate example.com : exit 5090ms (SIGTERM)
iter 10: tools/list only      : exit 34ms (clean)  | navigate example.com : exit 5082ms (SIGTERM)

100% / 100% — never flakes either way.

Expectation

After stdin EOF, the server closes the browser (and any other long-lived resources) and exits cleanly within a small bounded time (~1 s seems generous given the Chrome shutdown cost), in line with stdio MCP convention. Callers should not need to SIGTERM every page-loaded session to reclaim the process.

Investigation

build/src/bin/chrome-devtools-mcp-main.js has no shutdown handler — no process.on('SIGTERM' | 'SIGINT' | 'exit'), no process.stdin.on('end' | 'close'). It does await server.connect(transport) and ends. When stdin closes, the StdioServerTransport's reader returns EOF but nothing closes the browser, so the Chrome subprocess keeps the Node event loop ref'd and the process never exits.

There IS a process.stdin.on('end' / 'close') handler in build/src/telemetry/watchdog/main.js (the "Parent death detected (stdin end). Sending shutdown event..." log line some users have seen), but that's a separate telemetry sub-process; it doesn't reap the main server or the browser. There's also SIGTERM / SIGINT / SIGHUP handling in build/src/daemon/daemon.js, but the default stdio flow doesn't go through the daemon.

Tried as a workaround: sending tools/call close_page before stdin.end() (5/5 still SIGTERM) — closing the page doesn't reap Chrome itself.

Suggested fix

In chrome-devtools-mcp-main.ts (or wherever createMcpServer constructs the browser-owning context), register process.stdin.on('end', ...) and process.stdin.on('close', ...) handlers that close the browser and then process.exit(0). Same handler can be wired to SIGTERM and SIGINT so the bounded teardown also works for clients that signal the process instead of closing stdin.

MCP configuration

Direct spawn (no MCP client / coding agent involved). Default flags.

Chrome DevTools MCP version

1.0.1

Chrome version

148.0.7778.178

Coding agent version

N/A — reproduced by piping JSON-RPC directly into the MCP server's stdin.

Model version

N/A

Chat log

N/A

Node version

v24.11.1

Operating system

Linux

Extra checklist

  • I want to provide a PR to fix this bug

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions