Repository navigation
Transport errors are reported only on onerror; awaiting callers always get "MCP error -32000: Connection closed" #2775
Description
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.
Activity
BerkantACUN commentedon Sep 9, 2026
Two things found while probing this further.
A second trigger, and this one does not even reject. A UTF-8 BOM before the JSON — which Windows tooling and shell redirection produce without being asked — gives:
So the range across triggers is: buffer overflow gets
Connection closed, a BOM gets nothing at all. Same shape either way — the SDK identified the problem precisely and the caller cannot see it.#2678 is the other half of this. That PR fixes errors being dropped when no
onerrorhandler is set. This issue is the case where a handler is set and works — and the awaiting caller still ends up with a placeholder. Between them they cover both directions: nothing listening at all, and something listening that the caller has no access to.Worth mentioning because a fix for one is a natural place to fix the other: if
_onerrorretained the last error, #2678's logging path and this issue's rejection path could both read it, rather than each solving half.Third shape, filed separately as #2776 since the mechanism is different:
stderr: 'pipe'with an undrained stream deadlocks the session outright — no error, no rejection, no timeout. Not the same bug, but the same experience from the caller's side, which is why I am pointing at all three together rather than one at a time.errmakov commentedon Sep 11, 2026
I'd like to take the BOM case from your follow-up. I'll leave the other two parts (carrying the transport error into the rejection, and resyncing after an oversized message) to you, since you've proposed approaches for those and are waiting on direction.
Reproduced with a stdio server that writes a UTF-8 BOM before its
tools/listresponse, client request timeout 2 s:v2 is quieter because
ReadBuffer.readMessage()skips any line that throws aSyntaxError(to tolerate stray debug output from tools like tsx), so the BOM-prefixed response disappears without a trace.RFC 8259 §8.1 lets a parser ignore a leading BOM, so the plan is to strip
U+FEFFfrom the start of a line beforeJSON.parseinReadBuffer, with tests, onmainandv1.x. Could a maintainer assign this to me?Disclosure: this comment was drafted with Claude Code, which I'm also using for the fix.
fix(stdio): strip a leading UTF-8 byte order mark before parsing a me…
fix(core): carry the transport's last error into the Connection close…
fix(core): resync ReadBuffer at the next message boundary after an ov…
BerkantACUN commentedon Sep 11, 2026
Thanks — saw #2790, and I ran it against a BOM-writing server: 4 ms on the PR build versus a 2 s timeout on 2.0.0. Left that on the PR.
The other two are up:
Connection closed: ReadBuffer exceeded maximum size of 10485760 bytes, with the original onerror.cause. Forgotten if a message is delivered after the error, and not used for a close the caller asked for.ReadBufferresumes at the next newline after an oversized message instead of parsing its tail, and no longer re-buffers the remainder.They are independent of each other and of #2790 (#2793 touches
append()/clear(), #2790 touchesreadMessage()). #2793 is a draft only because of the one-open-PR limit for non-collaborators here; it is complete.felixweinberger commentedon Oct 5, 2026
From #153: the child's exit code would be useful in that error too.