Your MCP stdio server keeps running after the client dies (Node)
An MCP stdio server that holds a timer or a pool open does not exit when its client dies. Measured on SDK 1.30.0 and Node 24.8.0: it is reparented to init and keeps running. StdioServerTransport registers no stdin end-of-file listener. Here is the reproduction and the four-line fix.
On this page
Quick answer (September 2026). An MCP stdio server that holds anything open on the Node event loop, such as a cache-refresh timer, a connection pool or a log flusher, does not exit when its client dies. Measured on @modelcontextprotocol/sdk 1.30.0 and Node 24.8.0: a server whose only addition is a single setInterval survives both a client process.exit() and a client SIGKILL, gets reparented to init, and keeps running forever. A server with no background work survives neither, which is exactly why this never shows up in a hello-world. The cause is one listener that is not there. StdioServerTransport.start() registers data and error handlers on stdin and registers no end and no close handler, so nothing in the SDK reacts to the pipe closing. The current MCP specification says servers SHOULD exit when stdin reaches end-of-file. The fix is four listeners in your own startup code, and it also cuts clean-shutdown latency from 2010 ms to 13 ms.
The symptom you will actually see
You restart Claude Desktop, or your own agent process crashes, or you stop a script with control-C. Later you run ps and find three copies of your MCP server still resident, each one still holding its database pool, each one still firing its refresh timer. Nobody killed them because nobody was left to kill them.
This is easy to miss for one specific reason: the minimal server does not reproduce it. A server that does nothing between requests exits the moment its client goes away. So the tutorial you followed works, your first server works, and the problem appears only once you add the first piece of real infrastructure.
Everything below is measured on Node 24.8.0 with @modelcontextprotocol/sdk 1.30.0, with no API key and no model calls.
The harness
Seven files. The only dependency is the MCP SDK.
mkdir mcp-orphan-lab
cd mcp-orphan-lab
npm init -y
npm install --save-exact @modelcontextprotocol/sdk
First, the control: a server with no background work at all.
// server-plain.js
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const server = new McpServer({ name: 'plain', version: '1.0.0' });
server.registerTool('ping', { description: 'ping', inputSchema: {} }, async function () {
return { content: [{ type: 'text', text: 'pong' }] };
});
async function main() {
await server.connect(new StdioServerTransport());
}
main();
Now the same server with one ordinary timer. This is the whole difference.
// server-timer.js
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const fs = require('fs');
const path = require('path');
const server = new McpServer({ name: 'timer', version: '1.0.0' });
server.registerTool('ping', { description: 'ping', inputSchema: {} }, async function () {
return { content: [{ type: 'text', text: 'pong' }] };
});
// An ordinary cache-refresh timer. A connection pool or a log flusher
// would hold the event loop open in exactly the same way.
setInterval(function () {
fs.appendFileSync(path.join(__dirname, 'ticks.log'), 'tick pid=' + process.pid + '\n');
}, 1000);
async function main() {
await server.connect(new StdioServerTransport());
}
main();
A client that connects, records the server's pid, and then dies in one of three ways: a clean close(), a bare process.exit(), or by being killed from outside.
// client.js
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
const fs = require('fs');
const path = require('path');
const serverFile = process.argv[2];
const mode = process.argv[3];
async function main() {
const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(__dirname, serverFile)],
stderr: 'ignore'
});
const client = new Client({ name: 'harness', version: '1.0.0' });
await client.connect(transport);
await client.listTools();
fs.writeFileSync(path.join(__dirname, 'childpid.txt'), String(transport.pid));
console.log('client: connected, server pid ' + transport.pid);
if (mode === 'close') {
const started = Date.now();
await client.close();
console.log('client: close() returned after ' + (Date.now() - started) + ' ms');
process.exit(0);
}
if (mode === 'exit') {
console.log('client: calling process.exit(0) without close()');
process.exit(0);
}
if (mode === 'crash') {
console.log('client: idle, waiting to be killed');
setInterval(function () {}, 1000);
}
}
main();
And a driver that runs every combination, waits four seconds, and asks the operating system whether the server process is still there.
// probe.js
const { spawn, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
function isAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return false;
}
}
function sleep(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
async function probe(serverFile, mode, label) {
const pidFile = path.join(__dirname, 'childpid.txt');
try { fs.unlinkSync(pidFile); } catch (err) { /* first run */ }
console.log('=== ' + label + ': ' + serverFile + ', client ' + mode + ' ===');
const child = spawn(process.execPath, [path.join(__dirname, 'client.js'), serverFile, mode], { stdio: 'inherit' });
if (mode === 'crash') {
await sleep(3000);
child.kill('SIGKILL');
console.log('client: SIGKILLed by probe');
} else {
await new Promise(function (resolve) { child.on('close', resolve); });
}
await sleep(4000);
const pid = Number(fs.readFileSync(pidFile, 'utf8'));
if (isAlive(pid)) {
const ps = spawnSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8' });
console.log('RESULT: server pid ' + pid + ' is STILL ALIVE. ORPHAN. reparented to ppid ' + ps.stdout.trim());
try { process.kill(pid, 'SIGKILL'); } catch (err) { /* already gone */ }
} else {
console.log('RESULT: server pid ' + pid + ' is gone.');
}
console.log('');
}
async function main() {
const jobs = [
['server-plain.js', 'close', 'A'],
['server-plain.js', 'exit', 'B'],
['server-plain.js', 'crash', 'C'],
['server-timer.js', 'close', 'D'],
['server-timer.js', 'exit', 'E'],
['server-timer.js', 'crash', 'F'],
['server-fixed.js', 'close', 'G'],
['server-fixed.js', 'exit', 'H'],
['server-fixed.js', 'crash', 'I'],
['server-stubborn.js', 'close', 'J']
];
for (const job of jobs) {
await probe(job[0], job[1], job[2]);
}
}
main();
The remaining two server files are the fix and a worst case, and both appear further down. probe.js needs all four server files plus client.js sitting in the same directory before you run it.
The result
=== A: server-plain.js, client close ===
client: connected, server pid 3208297
client: close() returned after 11 ms
RESULT: server pid 3208297 is gone.
=== B: server-plain.js, client exit ===
client: connected, server pid 3208359
client: calling process.exit(0) without close()
RESULT: server pid 3208359 is gone.
=== C: server-plain.js, client crash ===
client: connected, server pid 3208397
client: idle, waiting to be killed
client: SIGKILLed by probe
RESULT: server pid 3208397 is gone.
=== D: server-timer.js, client close ===
client: connected, server pid 3208459
client: close() returned after 2010 ms
RESULT: server pid 3208459 is gone.
=== E: server-timer.js, client exit ===
client: connected, server pid 3208521
client: calling process.exit(0) without close()
RESULT: server pid 3208521 is STILL ALIVE. ORPHAN. reparented to ppid 1
=== F: server-timer.js, client crash ===
client: connected, server pid 3208560
client: idle, waiting to be killed
client: SIGKILLed by probe
RESULT: server pid 3208560 is STILL ALIVE. ORPHAN. reparented to ppid 1
=== G: server-fixed.js, client close ===
client: connected, server pid 3208636
client: close() returned after 13 ms
RESULT: server pid 3208636 is gone.
=== H: server-fixed.js, client exit ===
client: connected, server pid 3208707
client: calling process.exit(0) without close()
RESULT: server pid 3208707 is gone.
=== I: server-fixed.js, client crash ===
client: connected, server pid 3208756
client: idle, waiting to be killed
client: SIGKILLed by probe
RESULT: server pid 3208756 is gone.
=== J: server-stubborn.js, client close ===
client: connected, server pid 3208818
client: close() returned after 4006 ms
RESULT: server pid 3208818 is gone.
Read the middle block first. server-timer.js differs from server-plain.js only by a setInterval and the two require lines that timer needs, and that timer changes the outcome of both the exit case and the crash case from "gone" to "still alive, reparented to ppid 1". Reparenting to ppid 1 is the operating system telling you the parent is gone and nothing is coming to collect this process.
Two more things in that output are worth noticing now and are explained below: row D takes 2010 ms where row A takes 11 ms, and row J takes 4006 ms.
Why the plain server exits and the timer server does not
Nothing in the MCP SDK exits either of them. Node does.
When the client dies, its end of the stdin pipe closes and the server's stdin reaches end-of-file. In server-plain.js the stdin stream is the only thing holding the event loop open, so when it ends the loop drains and the process exits on its own. That is ordinary Node behaviour and it has nothing to do with MCP. In server-timer.js the interval timer is still referenced, the loop never drains, and the process runs forever.
So the plain server's clean exit is an accident of having no work to do. The moment your server has a reason to exist between requests, the accident stops happening.
You can confirm that the SDK itself is not involved. This file reads the installed transport and counts the stdin listeners it registers:
// inspect-transport.js
const fs = require('fs');
const file = require.resolve('@modelcontextprotocol/sdk/server/stdio.js');
const source = fs.readFileSync(file, 'utf8');
const pkgFile = file.split('@modelcontextprotocol/sdk')[0] + '@modelcontextprotocol/sdk/package.json';
console.log('sdk version: ' + JSON.parse(fs.readFileSync(pkgFile, 'utf8')).version);
console.log('node version: ' + process.version);
console.log('file: ' + file.split('node_modules/').pop());
console.log('stdin listeners registered by StdioServerTransport.start():');
for (const event of ['data', 'error', 'end', 'close']) {
const needle = "_stdin.on('" + event + "'";
console.log(' ' + event.padEnd(6) + ' : ' + (source.split(needle).length - 1));
}
sdk version: 1.30.0
node version: v24.8.0
file: @modelcontextprotocol/sdk/dist/cjs/server/stdio.js
stdin listeners registered by StdioServerTransport.start():
data : 1
error : 1
end : 0
close : 0
That is the entire mechanism. The transport subscribes to incoming bytes and to stream errors. End-of-file is neither of those, so the transport never learns that the conversation is over, onclose never fires, and your server never gets a chance to react.
What the specification asks for
The MCP specification is explicit about this, and it got more explicit recently. The current version, 2026-07-28, says in its stdio transport section:
The specification, verbatim: "Servers SHOULD exit promptly when their standard input is closed or reads return end-of-file. This is the primary graceful-shutdown signal and the only portable one, so honoring it reduces the need for forced termination."
The same page describes the client's side of the contract:
The specification, verbatim: "The client SHOULD initiate shutdown by: Closing the input stream to the child process (the server). Waiting for the server to exit. If the server does not exit within a reasonable time, forcibly terminating the process using the mechanism appropriate for the operating system."
Two things follow from reading both halves together.
The first is that the client-side SDK is correct. StdioClientTransport.close() implements exactly that ladder: it ends stdin, waits, sends SIGTERM, waits again, and finally sends SIGKILL. That is why row D succeeds at all, and the 2010 ms it takes is the SDK waiting out its own two-second grace period before escalating.
The second is that the entire ladder only runs if the client is alive to run it. Rows E and F are the cases where the client never gets to the ladder, and in those cases the only signal that ever reaches your server is stdin end-of-file, which is precisely the signal the transport ignores. The specification calls that signal "the only portable one" for a reason.
It is worth noting how this wording changed. The previous specification version, 2025-06-18, put the whole procedure on the client and said only that "the server MAY initiate shutdown by closing its output stream to the client and exiting". MAY became SHOULD, and the SDK has not caught up. The gap is filed upstream against the TypeScript SDK as issue 2002, against FastMCP as issue 264, and against the Java SDK as issue 936. It is a design-level gap, not a Node quirk.
The fix
Four listeners and a guarded shutdown function, added in your own startup code after connect().
// server-fixed.js
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const fs = require('fs');
const path = require('path');
const server = new McpServer({ name: 'fixed', version: '1.0.0' });
server.registerTool('ping', { description: 'ping', inputSchema: {} }, async function () {
return { content: [{ type: 'text', text: 'pong' }] };
});
setInterval(function () {
fs.appendFileSync(path.join(__dirname, 'ticks.log'), 'tick pid=' + process.pid + '\n');
}, 1000);
let shuttingDown = false;
function shutdown(reason) {
if (shuttingDown) { return; }
shuttingDown = true;
fs.appendFileSync(path.join(__dirname, 'ticks.log'), 'shutdown reason=' + reason + ' pid=' + process.pid + '\n');
server.close().catch(function () {}).finally(function () {
process.exit(0);
});
}
async function main() {
await server.connect(new StdioServerTransport());
// The four lines the SDK does not install for you.
process.stdin.on('end', function () { shutdown('stdin-end'); });
process.stdin.on('close', function () { shutdown('stdin-close'); });
process.on('SIGTERM', function () { shutdown('SIGTERM'); });
process.on('SIGINT', function () { shutdown('SIGINT'); });
}
main();
Rows G, H and I are that file. All three end in "gone", including the SIGKILL case where the client never ran any shutdown logic at all.
The shuttingDown guard matters more than it looks. Under a clean client shutdown you can receive stdin end-of-file and SIGTERM within the same two seconds, and without the guard you would run your cleanup twice.
The log the fixed server writes tells you which signal actually did the work:
grep shutdown ticks.log
shutdown reason=stdin-end pid=3208636
shutdown reason=stdin-end pid=3208707
shutdown reason=stdin-end pid=3208756
In all three rows it was stdin-end, never SIGTERM. In the two crash rows SIGTERM was never going to arrive. In the clean row it would have arrived, but two seconds later.
That is also why stdin-close never appears in that log. On this platform end always fired first. It is kept in the file because it costs nothing and the guard makes a double call harmless.
The second payoff: shutdown gets two seconds faster
Compare the three close rows. The millisecond figures move by a few milliseconds between runs; the two-second and four-second plateaus do not, because they are the client's own grace periods.
Scroll to see more
| server | what holds the loop open | close() returned after |
|---|---|---|
server-plain.js | nothing | 11 ms |
server-timer.js | one interval | 2010 ms |
server-fixed.js | one interval, plus the handlers | 13 ms |
Row D is not a failure. The client did eventually shut the server down, correctly, by escalating to SIGTERM. But it paid the full two-second grace period to do it, and a host that manages several stdio servers pays that per server.
It can be worse. This server has a SIGTERM handler that does some cleanup and forgets to call process.exit, which is an extremely easy thing to write:
// server-stubborn.js
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const server = new McpServer({ name: 'stubborn', version: '1.0.0' });
server.registerTool('ping', { description: 'ping', inputSchema: {} }, async function () {
return { content: [{ type: 'text', text: 'pong' }] };
});
setInterval(function () {}, 1000);
// A SIGTERM handler that does cleanup but forgets to exit. Very easy to write.
process.on('SIGTERM', function () { /* cleanup that never calls process.exit */ });
async function main() {
await server.connect(new StdioServerTransport());
}
main();
That is row J: close() returned after 4006 ms, because the client waited two seconds for stdin, then two more for SIGTERM, and then used SIGKILL. The server did die, but it died the one way that gives it no chance to flush anything.
Why "add a SIGTERM handler" is not the same advice
The standard production advice for MCP servers is to catch SIGTERM and SIGINT. The Grizzly Peak Software guide to state management in MCP servers is a good, detailed example and gives exactly that recommendation, with the reasoning that "Kubernetes, Docker, and most process managers send SIGTERM before killing your process".
That reasoning is correct for a container orchestrator and incomplete for a stdio MCP server, for two measured reasons.
SIGTERM is not the first signal. Per the specification the client's first action is closing stdin, and SIGTERM only arrives after the grace period. Handling SIGTERM alone means you always take the slow path, which is row D.
SIGTERM is also not a guaranteed signal. Rows E and F are the cases where SIGTERM never arrives at all, and those are the cases that produce the orphan. A SIGTERM handler cannot fire in a process whose parent died without sending one.
None of that makes the SIGTERM handler wrong. Keep it, because process managers really do use it. It is just not sufficient on its own, and the stdin handler is the one that covers the cases where nobody is left to signal you.
Things I measured that are worth knowing, and one I did not
Orphans accumulate. Three client crashes in a row leave three servers running, each reparented to init, each still ticking. Nothing deduplicates them and nothing reaps them. On a desktop host that restarts its agent several times a day, this is the mechanism behind "my machine got slow and I do not know why".
The fix does not exit early while a client is connected. In row I the server stayed alive through the whole connected, idle period and only exited when the client was killed. The handler fires on end-of-file, not on quiet.
A stdin that is already closed exits immediately, and that is correct. Launching server-fixed.js with stdin taken from /dev/null exits in 0.22 s. So does server-plain.js, in the same 0.22 s. If you want to poke at your server by hand, run it from a terminal, where stdin is a TTY and never reaches end-of-file.
This shutdown does not drain in-flight tool calls. shutdown() closes the server and exits, so a tool call that was still running is lost. That is a deliberate trade and the specification supports it, since the same page says that if the server exits unexpectedly "any in-flight requests are simply lost and the client can retry them against the fresh process". If your tools have side effects that must not be half-applied, make them idempotent rather than trying to finish them during shutdown.
What I did not measure: whether a server that writes to stdout on a timer eventually dies on its own from a broken pipe. It might, and it would not help, because a correct MCP server must not write anything to stdout that is not a protocol message. Every measurement here is Node. I did not test the Python SDK.
The short version
If your MCP stdio server holds anything open between requests, add these to your startup path today:
process.stdin.on('end', function () { shutdown('stdin-end'); });
process.stdin.on('close', function () { shutdown('stdin-close'); });
process.on('SIGTERM', function () { shutdown('SIGTERM'); });
process.on('SIGINT', function () { shutdown('SIGINT'); });
Your server will exit when its client dies instead of being adopted by init, and your clean shutdowns will stop costing two seconds each.
If you are still building the server itself, start with writing your first MCP server, and if your client is failing to reach the server at all rather than failing to let go of it, that is a different problem covered in MCP error -32000 connection closed. Desktop hosts are the most common place to hit the orphan, because they restart their servers on every configuration change and on every application restart, which is worth keeping in mind when you connect an MCP server to Claude Desktop.
Written by
Sofia NievesSofia works on agent evaluation and reliability. She writes about measuring LLM systems before and after they reach production.
Frequently asked questions
Why does my MCP server keep running after I close the host application?
Because nothing told it to stop. When the host dies, the only signal that reaches your server is end-of-file on stdin, and StdioServerTransport in @modelcontextprotocol/sdk 1.30.0 registers no listener for it. If your server holds anything open on the Node event loop, a timer, a connection pool or a log flusher, the process simply keeps running and is reparented to init. Measured on Node 24.8.0: a server whose only addition is one setInterval survives both a client process.exit() and a client SIGKILL.
Why did this never happen with my first hello-world MCP server?
A server with no background work has nothing holding its event loop open. When stdin reaches end-of-file the stream ends, the loop drains, and Node exits the process on its own. That is ordinary Node behaviour rather than anything the MCP SDK does. The orphan appears the first time you add real infrastructure, which is why the tutorial version works and the production version does not.
Is this a bug in the MCP TypeScript SDK?
It is a gap between the SDK and the current specification. The 2026-07-28 specification says servers SHOULD exit promptly when stdin is closed or reads return end-of-file, and calls that the primary and only portable graceful-shutdown signal. The previous 2025-06-18 version only said a server MAY close its output stream and exit. The client-side transport is correct and implements the full stdin, SIGTERM, SIGKILL ladder. It is the server transport that installs no end-of-file handler, and that is filed upstream against the TypeScript SDK, FastMCP and the Java SDK.
Is handling SIGTERM enough?
No, for two measured reasons. SIGTERM is not the first signal: the client closes stdin first and only escalates to SIGTERM after its grace period, so a SIGTERM-only server always takes the slow path and its clean shutdown costs 2010 ms instead of 13 ms. SIGTERM is also not a guaranteed signal: when the client is killed outright it never sends one, and that is exactly the case that produces the orphan. Keep the SIGTERM handler, because process managers use it, and add the stdin handler alongside it.
Will the stdin handler kill my server while a tool call is still running?
Yes, and that is the intended trade. The shutdown function closes the server and exits, so an in-flight tool call is lost. The specification supports exiting promptly, noting that if the server exits unexpectedly any in-flight requests are simply lost and the client can retry them against a fresh process. The right defence is to make tools with side effects idempotent rather than trying to finish them during shutdown.
Does the same problem affect Python and Java MCP servers?
Every measurement in this article is Node with @modelcontextprotocol/sdk 1.30.0, and the Python SDK was not tested. The Java SDK has an open issue asking for exactly the same stdin-close hook, so the gap is not specific to the Node implementation, but treat anything outside Node as unverified until you run the same probe against it.
Related tutorials
How to Build an MCP Server (2026): Your First Server, Wired to Claude
The Model Context Protocol (MCP) is a standard way to expose tools to any MCP-capable client, Claude Desktop, IDEs, or your own agents, so you write an integration once and reuse it everywhere. In this tutorial you build an MCP server in TypeScript that exposes a single typed tool over the stdio transport, test it with the MCP Inspector, then register it with Claude and call it from a real conversation. You will also learn the one rule that trips up everyone on stdio: never write to stdout. By the end you have a reusable server you can extend with your own tools.
MCP Error -32000 Connection Closed: What It Actually Means (2026)
MCP error -32000: Connection closed is generated inside the client SDK and is never sent by a server. It means your MCP server process died. Here is how to recover the real error, and a measured correction to the stdout advice (2026).
How to Add an MCP Server to Claude Desktop (2026)
The exact claude_desktop_config.json setup to connect a local MCP server to Claude Desktop, verify it with the MCP Inspector, and fix a server that will not show up.