Skip to content

Transport errors are reported only on onerror; awaiting callers always get "MCP error -32000: Connection closed" #2775

Open

Description

@BerkantACUN

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

  1. BerkantACUN commented on Sep 9, 2026

    @BerkantACUN
    Author

    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:

    [onerror] Unexpected token '', "{"jsonrpc"..." is not valid JSON
    [caller ] (never settles — hangs until the request timeout)
    

    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 onerror handler 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 _onerror retained 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.

  2. errmakov commented on Sep 11, 2026

    @errmakov

    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/list response, client request timeout 2 s:

    @modelcontextprotocol/sdk@1.30.0
      [onerror] Unexpected token '', "{"jsonrpc"... is not valid JSON
      [caller ] rejected after 2001 ms: MCP error -32001: Request timed out
    
    @modelcontextprotocol/client@2.0.0
      [caller ] rejected after 2002 ms: Request timed out      (no onerror at all)
    

    v2 is quieter because ReadBuffer.readMessage() skips any line that throws a SyntaxError (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+FEFF from the start of a line before JSON.parse in ReadBuffer, with tests, on main and v1.x. Could a maintainer assign this to me?

    Disclosure: this comment was drafted with Claude Code, which I'm also using for the fix.

  3. BerkantACUN commented on Sep 11, 2026

    @BerkantACUN
    Author

    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:

    They are independent of each other and of #2790 (#2793 touches append()/clear(), #2790 touches readMessage()). #2793 is a draft only because of the one-open-PR limit for non-collaborators here; it is complete.

  4. added
    v1Issues / PRs related to v1.x
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Sep 23, 2026
  5. felixweinberger commented on Oct 5, 2026

    @felixweinberger
    Contributor

    From #153: the child's exit code would be useful in that error too.

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

    v1Issues / PRs related to v1.xv2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions