StdioClientTransport accepts stderr: 'pipe' and exposes the stream as transport.stderr, which is the documented way to read a server's diagnostics. If nobody attaches a reader, a server that logs a normal amount to stderr blocks on write, stops reading stdin, and the session stops — silently. No error, no rejection, no transport-level timeout. await client.listTools() simply never returns.
Reproduction
// s.mjs — a server that logs to stderr, which is what stderr is for
import { createInterface } from 'node:readline';
const send = o => process.stdout.write(JSON.stringify(o) + '\n');
createInterface({ input: process.stdin }).on('line', line => {
const m = JSON.parse(line);
if (m.method === 'initialize') return send({ jsonrpc:'2.0', id:m.id, result:{
protocolVersion:'2025-06-18', capabilities:{tools:{}}, serverInfo:{name:'chatty',version:'1.0.0'} }});
if (m.method === 'notifications/initialized') return;
for (let i = 0; i < 200; i++) process.stderr.write('log line '.repeat(5000) + '\n');
send({ jsonrpc:'2.0', id:m.id, result:{ tools:[{name:'x',description:'d',inputSchema:{type:'object'}}] }});
});
// c.mjs
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const t = new StdioClientTransport({ command:'node', args:['s.mjs'], stderr:'pipe' });
const c = new Client({ name:'demo', version:'1.0.0' });
await c.connect(t);
if (process.argv[2] === 'drain') t.stderr.resume(); // the only difference
console.log('listTools()...');
const started = Date.now();
const r = await c.listTools();
console.log(`returned: ${r.tools.length} tools, ${Date.now()-started}ms`);
$ node c.mjs
listTools()...
← never returns
$ node c.mjs drain
listTools()...
returned: 1 tools, 19ms
@modelcontextprotocol/sdk@1.30.0, Node 22.22.1. Same hang on stderr: 'overlapped'; inherit and the default are fine, since the OS drains them.
Why this bites in practice
The pipe fills, the child blocks in write(2), and a blocked child is not reading stdin either — so the request it was answering never gets answered. Standard pipe behaviour, but three things make it a bad failure here:
- The option is offered by the SDK and there is no warning attached to it.
stderr: 'pipe' reads as "let me see the server's logs", not "you are now responsible for draining a pipe or the session dies".
- The failure is silent and looks like something else. No
onerror, no rejection. It presents as a slow or unresponsive server, so the natural first move is to blame the server or raise the request timeout — neither of which helps.
- Nothing unusual has to happen. The server in the repro is writing to stderr, which is what stderr is for. Anything with verbose logging enabled crosses the threshold on a single response.
A consumer who attaches a reader late — after connect() resolves but after a first request has already gone out — hits it intermittently, which is worse than hitting it every time.
Suggestion
Any of these would close it; the first seems most in keeping with the rest of the transport:
- When
stderr: 'pipe' is requested and nothing is attached by the time the transport starts, drain it internally (.resume()) so the child never blocks. A consumer that attaches a listener still gets the data; one that does not gets a working session instead of a hang.
- Alternatively, document the requirement on
StdioServerParameters.stderr and in the transport.stderr getter, in the form "you must consume this stream".
- Or surface it: if the stream is unread and buffered beyond some size, emit through
onerror rather than hanging.
Happy to open a PR for (1) with a regression test if that direction is right.
Related, though a different mechanism: #2678 fixes protocol errors being dropped when no onerror is set, and #2775 covers the real error reaching onerror while the awaiting caller gets Connection closed. This one is a third shape of the same underlying experience — the SDK is in a position to say what went wrong and the caller ends up with nothing.
StdioClientTransportacceptsstderr: 'pipe'and exposes the stream astransport.stderr, which is the documented way to read a server's diagnostics. If nobody attaches a reader, a server that logs a normal amount to stderr blocks on write, stops reading stdin, and the session stops — silently. No error, no rejection, no transport-level timeout.await client.listTools()simply never returns.Reproduction
@modelcontextprotocol/sdk@1.30.0, Node 22.22.1. Same hang onstderr: 'overlapped';inheritand the default are fine, since the OS drains them.Why this bites in practice
The pipe fills, the child blocks in
write(2), and a blocked child is not reading stdin either — so the request it was answering never gets answered. Standard pipe behaviour, but three things make it a bad failure here:stderr: 'pipe'reads as "let me see the server's logs", not "you are now responsible for draining a pipe or the session dies".onerror, no rejection. It presents as a slow or unresponsive server, so the natural first move is to blame the server or raise the request timeout — neither of which helps.A consumer who attaches a reader late — after
connect()resolves but after a first request has already gone out — hits it intermittently, which is worse than hitting it every time.Suggestion
Any of these would close it; the first seems most in keeping with the rest of the transport:
stderr: 'pipe'is requested and nothing is attached by the time the transport starts, drain it internally (.resume()) so the child never blocks. A consumer that attaches a listener still gets the data; one that does not gets a working session instead of a hang.StdioServerParameters.stderrand in thetransport.stderrgetter, in the form "you must consume this stream".onerrorrather than hanging.Happy to open a PR for (1) with a regression test if that direction is right.
Related, though a different mechanism: #2678 fixes protocol errors being dropped when no
onerroris set, and #2775 covers the real error reachingonerrorwhile the awaiting caller getsConnection closed. This one is a third shape of the same underlying experience — the SDK is in a position to say what went wrong and the caller ends up with nothing.