When a stdio transport dies, the SDK knows exactly why and says so — but only on transport.onerror. The pending request rejects with a generic Connection closed, so code that does the ordinary thing (await client.listTools()) is told the connection dropped and nothing else.
I hit this building a scanner that connects to MCP servers it has no reason to trust, so misbehaving servers are the normal case rather than the edge case. Every one of them looks identical from the call site.
Reproduction
Self-contained, no external repo. A server that answers initialize normally and then returns a tools/list result larger than the read buffer:
// server.mjs
import { createInterface } from 'node:readline';
const send = o => process.stdout.write(JSON.stringify(o) + '\n');
createInterface({ input: process.stdin }).on('line', line => {
const msg = JSON.parse(line);
if (msg.method === 'initialize') {
return send({ jsonrpc: '2.0', id: msg.id, result: {
protocolVersion: '2025-06-18',
capabilities: { tools: {} },
serverInfo: { name: 'big', version: '1.0.0' }
}});
}
if (msg.method === 'notifications/initialized') return;
send({ jsonrpc: '2.0', id: msg.id, result: {
tools: [{ name: 'big', description: 'A'.repeat(20 * 1024 * 1024), inputSchema: {} }]
}});
});
// client.mjs
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const t = new StdioClientTransport({ command: 'node', args: ['server.mjs'] });
t.onerror = e => console.log('[onerror]', e.message);
const c = new Client({ name: 'probe', version: '1.0.0' });
await c.connect(t);
try {
await c.listTools();
} catch (e) {
console.log('[caller ]', e.message);
}
Output on @modelcontextprotocol/sdk@1.30.0, Node 22:
[onerror] ReadBuffer exceeded maximum size of 10485760 bytes
[onerror] Unexpected token 'A', "AAAAAAAAAA"... is not valid JSON
[caller ] MCP error -32000: Connection closed
Two things in that output
1. The diagnosis doesn't reach the caller. ReadBuffer exceeded maximum size of 10485760 bytes is exactly what someone needs — it names the cause and implies the fix (maxBufferSize, added in #2239). The awaiting call gets Connection closed, which is true of every transport failure and therefore says nothing.
This is not specific to the buffer limit. #1049 reaches the same generic message from a completely different cause — a child process exiting straight after spawn — and #2552 is working on a third path to it. The pattern across all three is the same: the real error exists, it goes to onerror, and the rejection carries a placeholder. onerror is a side channel that a caller using await never sees unless they knew in advance to wire it up.
2. The stream isn't resynchronised after an oversized message. The second onerror line is a JSON parse failure on "AAAAAAAAAA"... — leftover bytes from the message that was just rejected, read as if they were the start of a new one. ReadBuffer.append calls this.clear() before throwing, but the rest of the oversized message is still arriving, so the next chunk lands in an empty buffer mid-message. That turns one accurate error into two, the second of which points at nothing real. Recovering would mean discarding bytes until the next newline rather than clearing and continuing.
Suggestion
For the first: keep the last transport error and use it when rejecting pending requests, so the rejection carries the cause and Connection closed stays the fallback for when there genuinely isn't one. That would fix #1049 and this one in the same place, and it doesn't change the public API.
For the second: after an oversized message, drop input up to the next newline instead of clearing, so the parser resumes at a real message boundary.
Happy to open a PR for either or both if the approach sounds right — I'd rather check the direction first than guess at it, since the first one touches how every transport failure surfaces.
Unrelated, noticed while checking this: #88 asks for a configurable stdio buffer limit and looks resolved. The SDK uses spawn, not exec, so Node's maxBuffer never applied; #2239 added maxBufferSize on StdioServerParameters in June, which is the configurable limit that issue was asking for. Might be closeable.
When a stdio transport dies, the SDK knows exactly why and says so — but only on
transport.onerror. The pending request rejects with a genericConnection closed, so code that does the ordinary thing (await client.listTools()) is told the connection dropped and nothing else.I hit this building a scanner that connects to MCP servers it has no reason to trust, so misbehaving servers are the normal case rather than the edge case. Every one of them looks identical from the call site.
Reproduction
Self-contained, no external repo. A server that answers
initializenormally and then returns atools/listresult larger than the read buffer:Output on
@modelcontextprotocol/sdk@1.30.0, Node 22:Two things in that output
1. The diagnosis doesn't reach the caller.
ReadBuffer exceeded maximum size of 10485760 bytesis exactly what someone needs — it names the cause and implies the fix (maxBufferSize, added in #2239). The awaiting call getsConnection closed, which is true of every transport failure and therefore says nothing.This is not specific to the buffer limit. #1049 reaches the same generic message from a completely different cause — a child process exiting straight after spawn — and #2552 is working on a third path to it. The pattern across all three is the same: the real error exists, it goes to
onerror, and the rejection carries a placeholder.onerroris a side channel that a caller usingawaitnever sees unless they knew in advance to wire it up.2. The stream isn't resynchronised after an oversized message. The second
onerrorline is a JSON parse failure on"AAAAAAAAAA"...— leftover bytes from the message that was just rejected, read as if they were the start of a new one.ReadBuffer.appendcallsthis.clear()before throwing, but the rest of the oversized message is still arriving, so the next chunk lands in an empty buffer mid-message. That turns one accurate error into two, the second of which points at nothing real. Recovering would mean discarding bytes until the next newline rather than clearing and continuing.Suggestion
For the first: keep the last transport error and use it when rejecting pending requests, so the rejection carries the cause and
Connection closedstays the fallback for when there genuinely isn't one. That would fix #1049 and this one in the same place, and it doesn't change the public API.For the second: after an oversized message, drop input up to the next newline instead of clearing, so the parser resumes at a real message boundary.
Happy to open a PR for either or both if the approach sounds right — I'd rather check the direction first than guess at it, since the first one touches how every transport failure surfaces.
Unrelated, noticed while checking this: #88 asks for a configurable stdio buffer limit and looks resolved. The SDK uses
spawn, notexec, so Node'smaxBuffernever applied; #2239 addedmaxBufferSizeonStdioServerParametersin June, which is the configurable limit that issue was asking for. Might be closeable.