[BUG] Windows: at session end, stdio MCP servers are tree-killed before their stdin is closed
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
On Windows, when a session ends through Claude Code's own shutdown, the exit cleanup for a connected stdio MCP server starts taskkill.exe /PID <server pid> /T /F first and closes the server's stdin only after that. The server reads end-of-file 5 to 113 ms after taskkill has started, and taskkill kills it, together with every process it started, about half a second to a second later. A server that needs longer than that to shut down never finishes.
Measured on Windows 11 (build 26300) with Claude Code 2.1.288 (standalone CLI) and 2.1.287 (the binary bundled with the VS Code extension), 168 session ends:
- Ways the session ended:
claude -pfinishing its turn;--input-format stream-jsonover pipes with stdin closed, which is how the VS Code extension's SDK transport ends a session;/exit, Ctrl+C twice, and closing the terminal (pseudoconsole closed, claude exits 129) in the interactive UI; and the SDK'sclose()sequence reproduced outside VS Code. - In all 168 runs the debug log has
MCP server "<name>": Terminating MCP server process tree. An observer outside Claude Code's process tree sawtaskkill.exe /PID <server pid> /T /Fstart as a child of claude.exe 5 to 113 ms before the server read end-of-file (166 of 168 runs; in the other two the server exited within 7 ms of end-of-file and the poll missed the short-lived taskkill). - The test server waits a set time after end-of-file and then exits on its own with exit code 77. Three runs per wait per scenario, waits of 0, 100, 1000, 3000, 6000 and 15000 ms:
- 100 ms or less: exited on its own every time (57 of 57).
- 1 s: exited on its own 4 times in 27, killed the other 23.
- 3 s or more: killed every time (84 of 84).
- Killed servers died 533 to 1,154 ms after end-of-file (median 794 ms over all 107 kills), with exit code 1. The child process each server had started died at the same instant.
- The time a server gets is how long taskkill.exe takes to start and walk the process tree, not a grace period. Claude Code itself exited 11 to 202 ms after the server died.
The cleanup has two branches for a stdio server whose pid is known. The first starts the tree-kill and then closes the MCP client; closing the client ends the server's stdin, and its stdio transport would wait up to 2 s for the process to exit, but by then taskkill is already running. The second branch, which sends SIGINT, waits 100 ms, sends SIGTERM, waits 400 ms and then sends SIGKILL, is keyed to the same condition as the first and can no longer run. None of the 168 debug logs contains Sending SIGINT to MCP server process, the line #93087 quotes from version 2.1.266 on macOS.
What Should Happen?
Follow the shutdown order of the MCP specification, with the tree-kill kept as the backstop:
- Close the server's stdin. Closing the client already does this, and its transport already waits up to 2 s for the process to exit.
- Wait a bounded time for the server to exit.
- Run
taskkill /PID <pid> /T /Fonly for a server that is still running.
MCP specification, revision 2026-07-28, stdio transport, "Shutdown" (https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#shutdown): "The client SHOULD initiate shutdown by: 1. Closing the input stream to the child process (the server). 2. Waiting for the server to exit. 3. If the server does not exit within a reasonable time, forcibly terminating the process using the mechanism appropriate for the operating system." The same section calls stdin end-of-file "the primary graceful-shutdown signal and the only portable one". Revision 2025-11-25, which the measured sessions negotiated, also closes the input stream first (basic/lifecycle, "Shutdown", "stdio").
A server that hangs is still killed and nothing is left running, so the fixes asked for in #1935 and #93087 are unaffected. The budget is already there: on Windows the VS Code extension's SDK transport ends claude's stdin and waits 2 s and then 5 s more before it kills claude.exe, and in these runs claude.exe exited 34 to 1,604 ms after its stdin was closed (median 756 ms, 78 runs).
Error Messages/Logs
[DEBUG] MCP server "slow": Terminating MCP server process tree
[DEBUG] MCP server "slow": STDIO connection closed after 0s (cleanly)
C:\WINDOWS\System32\taskkill.exe /PID <server pid> /T /F
started
stdin EOF; cleaning up for 3000 ms
(no "cleanup done" line; the server exited with code 1)
Steps to Reproduce
- Save this as slow-exit-server.js (Node.js, no dependencies). It answers initialize, tools/list and tools/call, and after stdin end-of-file it waits for the number of milliseconds in its first argument (cleanupMs) before it exits:
``js${new Date().toISOString()} pid=${process.pid} ${m}\n
const fs = require('fs');
const cleanupMs = Number(process.argv[2] || 3000);
const logFile = process.argv[3] || 'slow-exit.log';
const log = (m) => fs.appendFileSync(logFile, );stdin EOF; cleaning up for ${cleanupMs} ms
log('started');
let buf = '';
process.stdin.on('data', (d) => {
buf += d;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
if (!line.trim()) continue;
const msg = JSON.parse(line);
if (msg.id === undefined) continue;
let result = {};
if (msg.method === 'initialize') result = { protocolVersion: msg.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'slow-exit', version: '1.0.0' } };
if (msg.method === 'tools/list') result = { tools: [{ name: 'ping', description: 'Returns pong.', inputSchema: { type: 'object', properties: {} } }] };
if (msg.method === 'tools/call') result = { content: [{ type: 'text', text: 'pong' }] };
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }) + '\n');
}
});
process.stdin.on('end', () => {
log();``
setTimeout(() => { log('cleanup done'); process.exit(0); }, cleanupMs);
});
- Save this as mcp.json, with real paths:
``json``
{"mcpServers":{"slow":{"type":"stdio","command":"node","args":["C:\\path\\to\\slow-exit-server.js","3000","C:\\path\\to\\slow-exit.log"]}}}
- Run:
````
claude -p "Call the slow ping tool." --mcp-config mcp.json --strict-mcp-config --allowedTools mcp__slow__ping --debug-file claude-debug.log
- After claude has exited, slow-exit.log ends with
stdin EOF; cleaning up for 3000 msand has nocleanup done. claude-debug.log hasTerminating MCP server process tree. - Change 3000 to 50 and run again. The log now ends with
cleanup done. (Both steps were run on 2026-10-03 with 2.1.288 as written: the 3000 run exited 1 with taskkill.exe observed, the 50 run exited 0.)
The same happens when the session is ended with /exit, with Ctrl+C, by closing the terminal, or by closing stdin of claude --input-format stream-json --output-format stream-json.
Claude Model
Other
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.288 (Claude Code). Also 2.1.287 (Claude Code), the binary bundled with the VS Code extension.
Platform
Other
Operating System
Windows
Terminal/Shell
Non-interactive/CI environment
Additional Information
- I have searched existing issues and this hasn't been reported yet. Closest: #1935, #93087, #57740, #79567, #76306 and #33947 ask for MCP servers to be terminated more reliably at exit. #40207 and #86040 are about kills in the middle of a session. None asks for the stdin close and bounded wait that the MCP specification puts before forced termination. The termination those issues asked for would stay, but after the stdin close instead of before it.
- I am using the latest version of Claude Code (2.1.288, the npm "latest" tag on 2026-10-03)
- Error Messages/Logs, in order:
- Claude Code debug log at session end, from the repro above with a 3 s server
- Child process of claude.exe started at the same moment, recorded by the observer
- The server's own log
- Claude Model: Other. The measured runs used a local Messages API stub through ANTHROPIC_BASE_URL; the model plays no part.
- Is this a regression? I don't know. The unreachable SIGINT, SIGTERM and SIGKILL branch suggests the order changed, and #93087 shows that branch running on 2.1.266 on macOS. I have no measurement of an older version on Windows.
- Platform: Other (a local Messages API stub; the bug does not depend on the API platform)
- Terminal/Shell: Non-interactive/CI environment (headless
-p, and stream-json over pipes). The interactive cases ran in a pseudoconsole. - An MCP server that hosts browser sessions closes its browsers when its stdin reaches end-of-file. If the kill comes first, the user loses recent logins, though nothing is corrupted. In a separate measurement (2026-09-23, Chromium 154.0.8037.0, Windows), a twelve-process browser tree ended with
taskkill /T /Freopened with all 19 of its SQLite stores passingPRAGMA integrity_check, and with 0 localStorage keys and 0 cookies, against the 40 keys and the cookie that a gracefulBrowser.closeafter the same writes kept. - Everything above was measured on Windows; I did not test macOS or Linux.
- When claude.exe itself is terminated, for example when the VS Code extension host exits, the server dies 0 to 19 ms later with exit code 0 through the job object Claude Code puts it in, and it reads no end-of-file at all (90 of 90 runs). That is the safety net for a crash, and I am not asking to change it.
- Method: the observer launched Claude Code itself, followed its process tree by parent pid, and opened a handle on every descendant as it appeared, so exit times and exit codes come from the kernel. The test server logged end-of-file and its own exit against the same clock.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗