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
Description of the bug
chrome-devtools-mcpdoes not exit when its stdin is closed if anavigate_page(or any tool that opens a Chrome instance) has been issued during the session. The MCP protocol does not define ashutdownRPC and convention across stdio-based MCP servers is to exit on stdin EOF; without that, callers that cleanly close stdin have to fall back toSIGTERM/SIGKILLon 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:
repro.mjs:Observed
Across 10 invocations (40 total scenarios; clean Linux box, no other tabs, no MCP client):
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
SIGTERMevery page-loaded session to reclaim the process.Investigation
build/src/bin/chrome-devtools-mcp-main.jshas no shutdown handler — noprocess.on('SIGTERM' | 'SIGINT' | 'exit'), noprocess.stdin.on('end' | 'close'). It doesawait 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 inbuild/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 alsoSIGTERM/SIGINT/SIGHUPhandling inbuild/src/daemon/daemon.js, but the default stdio flow doesn't go through the daemon.Tried as a workaround: sending
tools/call close_pagebeforestdin.end()(5/5 still SIGTERM) — closing the page doesn't reap Chrome itself.Suggested fix
In
chrome-devtools-mcp-main.ts(or wherevercreateMcpServerconstructs the browser-owning context), registerprocess.stdin.on('end', ...)andprocess.stdin.on('close', ...)handlers that close the browser and thenprocess.exit(0). Same handler can be wired toSIGTERMandSIGINTso 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