Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/connection-closed-carries-cause.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

Carry the transport's last reported error into the `Connection closed` rejection. When a transport reports why it is closing — a `ReadBuffer` overflow, a stream error, a dropped socket — and then closes, every pending request was rejected with a bare `SdkError('Connection closed')` and the reason went only to `onerror`. Code doing the ordinary thing (`await client.listTools()`) was told the connection dropped and nothing else.

The rejection now reads `Connection closed: <reason>` with the transport's error as `cause`. Plain `Connection closed` remains the message when the transport reported nothing, when the caller closed the connection itself, and when a message was delivered after the error (the transport recovered, so the error is not why it closed). `SdkErrorCode.ConnectionClosed` is unchanged.
2 changes: 1 addition & 1 deletion packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2264,7 +2264,7 @@ export class Client extends Protocol<ClientContext> {
*/
protected override _onclose(): void {
if (this._listenState.size > 0) {
const reason = new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed');
const reason = this._connectionClosedError();
for (const entry of this._listenState.values()) {
entry.settle({ cause: 'remote', error: reason });
}
Expand Down
39 changes: 39 additions & 0 deletions packages/client/test/client/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { tmpdir } from 'node:os';

import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';

import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';

import { Client } from '../../src/client/client';
import type { StdioServerParameters } from '../../src/client/stdio';
import { StdioClientTransport } from '../../src/client/stdio';

Expand Down Expand Up @@ -122,6 +125,42 @@ test('should fire onerror and close when ReadBuffer overflows', async () => {
await closed;
});

test('an awaiting caller learns why the connection closed when the read buffer overflows', async () => {
// The shape from #2775: the transport reports the cause on onerror and
// closes; without a reason on the close, `await client.listTools()` was
// rejected with a bare `Connection closed` and the diagnosis was only
// visible to code that had wired up onerror in advance.
const server = String.raw`
const { createInterface } = require('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: 'big', version: '1.0.0' } } });
}
if (m.method === 'notifications/initialized') return;
send({ jsonrpc: '2.0', id: m.id, result: {
tools: [{ name: 'big', description: 'A'.repeat(4096), inputSchema: { type: 'object' } }] } });
});
`;
const transport = new StdioClientTransport({
command: process.execPath,
args: ['-e', server],
maxBufferSize: 1024
});
const client = new Client({ name: 'demo', version: '1.0.0' });
await client.connect(transport);

const error = await client.listTools().catch(e => e);
expect(error).toBeInstanceOf(SdkError);
expect(error.code).toBe(SdkErrorCode.ConnectionClosed);
expect(error.message).toMatch(/^Connection closed: ReadBuffer exceeded maximum size of 1024 bytes/);
expect(error.cause).toBeInstanceOf(Error);
expect((error.cause as Error).message).toMatch(/ReadBuffer exceeded maximum size/);
}, 10_000);

test('_dispose releases the parent-side pipe handles even when a helper process holds the child stdio', async () => {
// The rmcp-holding anatomy: the child exits, but a helper it spawned with
// stdio: 'inherit' keeps the pipe write ends open. Awaiting 'exit' settles
Expand Down
36 changes: 35 additions & 1 deletion packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,15 @@ export abstract class Protocol<ContextT extends BaseContext> {
private _timeoutInfo: Map<number, TimeoutInfo> = new Map();
private _pendingDebouncedNotifications = new Set<string>();

/**
* The most recent error the transport reported since the last message it
* delivered. Read once, in `_onclose`, so the `Connection closed` rejection
* can name the cause instead of a placeholder. A message arriving after an
* error means the transport recovered from it, so it is not why the
* connection closed and is forgotten.
*/
private _lastTransportError?: Error;

/**
* The protocol version negotiated for the current connection (`undefined`
* before negotiation completes), which determines the wire era this
Expand Down Expand Up @@ -784,6 +793,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
*/
async connect(transport: Transport): Promise<void> {
this._transport = transport;
this._lastTransportError = undefined;
const _onclose = this.transport?.onclose;
this._transport.onclose = () => {
try {
Expand All @@ -795,12 +805,14 @@ export abstract class Protocol<ContextT extends BaseContext> {

const _onerror = this.transport?.onerror;
this._transport.onerror = (error: Error) => {
this._lastTransportError = error;
_onerror?.(error);
this._onerror(error);
};

const _onmessage = this._transport?.onmessage;
this._transport.onmessage = (message, extra) => {
this._lastTransportError = undefined;
_onmessage?.(message, extra);
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
this._onresponse(message);
Expand Down Expand Up @@ -838,9 +850,10 @@ export abstract class Protocol<ContextT extends BaseContext> {
const requestHandlerAbortControllers = this._requestHandlerAbortControllers;
this._requestHandlerAbortControllers = new Map();

const error = new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed');
const error = this._connectionClosedError();

this._transport = undefined;
this._lastTransportError = undefined;

try {
this.onclose?.();
Expand All @@ -859,6 +872,24 @@ export abstract class Protocol<ContextT extends BaseContext> {
this.onerror?.(error);
}

/**
* The error every pending request is settled with when the connection
* closes. When the transport reported why — a read buffer overflow, a
* stream error, a dropped socket — that error is the `cause` and its
* message is appended, so an awaiting caller learns what happened without
* having wired up `onerror` in advance. Plain `Connection closed` is the
* fallback for a close with no reported reason. Subclasses that settle
* their own pending state on close should use this rather than construct
* a second, reason-less error.
*/
protected _connectionClosedError(): SdkError {
const cause = this._lastTransportError;
if (!cause) {
return new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed');
}
return new SdkError(SdkErrorCode.ConnectionClosed, `Connection closed: ${cause.message}`, undefined, { cause });
}

/**
* Inbound-notification dispatch. Subclass overrides MUST delegate
* unmatched traffic to `super._onnotification(rawNotification, extra)` —
Expand Down Expand Up @@ -1225,6 +1256,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
* Closes the connection.
*/
async close(): Promise<void> {
// A close the caller asked for has no transport-reported reason, even
// if the transport complained about something earlier.
this._lastTransportError = undefined;
await this._transport?.close();
}

Expand Down
69 changes: 69 additions & 0 deletions packages/core-internal/test/shared/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,75 @@ describe('protocol tests', () => {
expect((abortReason as SdkError).code).toBe(SdkErrorCode.ConnectionClosed);
});

describe('close reason', () => {
const resultSchema = z.object({});

test('rejects pending requests with the last transport error as the cause', async () => {
await protocol.connect(transport);
const pending = testRequest(protocol, { method: 'example', params: {} }, resultSchema);

const reported = new Error('ReadBuffer exceeded maximum size of 100 bytes');
transport.onerror?.(reported);
await transport.close();

const error = await pending.catch(e => e);
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed);
expect((error as SdkError).message).toBe('Connection closed: ReadBuffer exceeded maximum size of 100 bytes');
expect((error as SdkError).cause).toBe(reported);
});

test('falls back to a plain Connection closed when the transport reported nothing', async () => {
await protocol.connect(transport);
const pending = testRequest(protocol, { method: 'example', params: {} }, resultSchema);

await transport.close();

const error = await pending.catch(e => e);
expect((error as SdkError).message).toBe('Connection closed');
expect((error as SdkError).cause).toBeUndefined();
});

test('forgets a transport error once a later message shows the transport recovered', async () => {
await protocol.connect(transport);
const pending = testRequest(protocol, { method: 'example', params: {} }, resultSchema);

transport.onerror?.(new Error('a parse error the transport skipped past'));
transport.onmessage?.({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: 'x', progress: 1 } });
await transport.close();

const error = await pending.catch(e => e);
expect((error as SdkError).message).toBe('Connection closed');
expect((error as SdkError).cause).toBeUndefined();
});

test('does not blame an earlier transport error for a close the caller asked for', async () => {
await protocol.connect(transport);
const pending = testRequest(protocol, { method: 'example', params: {} }, resultSchema);

transport.onerror?.(new Error('an earlier complaint'));
await protocol.close();

const error = await pending.catch(e => e);
expect((error as SdkError).message).toBe('Connection closed');
expect((error as SdkError).cause).toBeUndefined();
});

test('does not carry a reason across reconnects', async () => {
await protocol.connect(transport);
transport.onerror?.(new Error('from the first connection'));
await transport.close();

const second = new MockTransport();
await protocol.connect(second);
const pending = testRequest(protocol, { method: 'example', params: {} }, resultSchema);
await second.close();

const error = await pending.catch(e => e);
expect((error as SdkError).message).toBe('Connection closed');
});
});

test('should remove abort listener from caller signal when request settles', async () => {
await protocol.connect(transport);

Expand Down
Loading