Skip to content

stderr: 'pipe' deadlocks the session if the consumer never reads transport.stderr #2776

Description

@BerkantACUN

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:

  1. 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.
  2. Alternatively, document the requirement on StdioServerParameters.stderr and in the transport.stderr getter, in the form "you must consume this stream".
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions