modelcontextprotocol / modelcontextprotocol/typescript-sdk

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

Open
#2,776 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at the StdioClientTransport stderr handling and reproduce the hang with stderr: 'pipe' while no consumer reads transport.stderr. Compare the pipe and default or inherit behavior, then add a regression test covering a chatty server without an attached reader; done means the request completes without blocking and a consumer can still read stderr.

Written by the indexing model from the issue text.

Assessment

Tech stack
node.js, typescript
Domain
api, backend, testing
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.