modelcontextprotocol / modelcontextprotocol/typescript-sdk

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

Open
#2,775 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

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.

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 with the server.mjs/client.mjs reproduction, then trace StdioClientTransport error handling, pending request rejection, and ReadBuffer.append. Check how transport.onerror errors are retained and how oversized input is handled after ReadBuffer.clear(). Done means awaiting callers receive the underlying transport error and oversized messages do not cause a spurious follow-up parse error.

Written by the indexing model from the issue text.

Assessment

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.