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
6 changes: 6 additions & 0 deletions .changeset/tidy-hands-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/core-internal': patch
---

Bound the legacy `Client.connect()` handshake's `notifications/initialized` POST by a separate per-stage timeout (the configured `timeout`, or the default request timeout), rather than a shared total connection budget. Only caller cancellation during the `notifications/initialized` stage rejects with the original abort reason and aborts the POST. The `initialize` stage retains `request()` behavior: non-`SdkError` abort reasons are wrapped in an `SdkError` with code `RequestTimeout`.
40 changes: 38 additions & 2 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,11 @@ export class Client extends Protocol<ClientContext> {
/**
* Connects to a server via the given transport and performs the MCP initialization handshake.
*
* In the legacy handshake, `options.timeout` applies independently to the
* initialize request and the initialized notification POST, not to their combined duration.
* Only the initialized stage rejects with the original caller abort reason; initialize
* uses request() behavior, wrapping non-SdkError reasons in RequestTimeout.
*
* @example Basic usage (stdio)
* ```ts source="./client.examples.ts#Client_connect_stdio"
* const client = new Client({ name: 'my-client', version: '1.0.0' });
Expand Down Expand Up @@ -1097,9 +1102,40 @@ export class Client extends Protocol<ClientContext> {

this._instructions = result.instructions;

await this.notification({
method: 'notifications/initialized'
// Each legacy handshake stage gets its own timeout, matching per-request timeout semantics.
const initializedAbort = new AbortController();
if (options?.signal?.aborted) throw options.signal.reason;

const notificationTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let abortFromConnect: (() => void) | undefined;
const abortPromise = new Promise<never>((_resolve, reject) => {
abortFromConnect = () => {
const error = options?.signal?.reason;
initializedAbort.abort(error);
reject(error);
};
options?.signal?.addEventListener('abort', abortFromConnect, { once: true });
});
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => {
const timeoutError = new SdkError(SdkErrorCode.RequestTimeout, 'Timed out waiting for notifications/initialized POST', {
timeout: notificationTimeout
});
initializedAbort.abort(timeoutError);
reject(timeoutError);
}, notificationTimeout);
});
try {
await Promise.race([
this.notification({ method: 'notifications/initialized' }, { requestSignal: initializedAbort.signal }),
abortPromise,
timeoutPromise
]);
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
if (abortFromConnect) options?.signal?.removeEventListener('abort', abortFromConnect);
}

// Handshake completion: the negotiated version becomes the
// instance's connection state, and with it the wire era for
Expand Down
180 changes: 180 additions & 0 deletions packages/client/test/client/connectHandshakeTimeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { JSONRPCMessage, Transport, TransportSendOptions } from '@modelcontextprotocol/core-internal';
import { LATEST_PROTOCOL_VERSION, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
import { afterEach, describe, expect, test, vi } from 'vitest';

import { Client } from '../../src/client/client';

class HangingInitializedTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;

initializedSignal: AbortSignal | undefined;
private initializeSent!: () => void;
readonly initializeSentPromise = new Promise<void>(resolve => {
this.initializeSent = resolve;
});
closeCalls = 0;
private initializedSent!: () => void;
readonly initializedSentPromise = new Promise<void>(resolve => {
this.initializedSent = resolve;
});

constructor(
private readonly honorRequestSignal = true,
private readonly initializeDelay = 0,
private readonly afterInitialize?: () => void,
private readonly hangInitialize = false
) {}

async start(): Promise<void> {}

async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
if (!('method' in message)) return;

if (message.method === 'initialize' && 'id' in message) {
this.initializeSent();
if (this.hangInitialize) return;
if (this.initializeDelay) await new Promise(resolve => setTimeout(resolve, this.initializeDelay));
this.onmessage?.({
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
serverInfo: { name: 'hanging-server', version: '1.0.0' }
}
});
this.afterInitialize?.();
return;
}

if (message.method === 'notifications/initialized') {
const signal = options?.requestSignal;
this.initializedSignal = signal;
this.initializedSent();

// Model a per-request HTTP transport whose POST remains pending
// until request-level cancellation aborts it.
if (!this.honorRequestSignal) return await new Promise<void>(() => {});
return await new Promise<void>((_resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
});
}
}

async close(): Promise<void> {
this.closeCalls++;
this.onclose?.();
}
}

function createClient(): Client {
return new Client({ name: 'connect-timeout-test', version: '1.0.0' });
}

describe('Client.connect initialized notification cancellation', () => {
afterEach(() => vi.useRealTimers());
test('bounds the notifications/initialized POST by its own stage timeout', async () => {
// The timeout must settle connect() itself, even if a custom transport
// ignores the optional request signal.
const transport = new HangingInitializedTransport(false);
const client = createClient();

await expect(client.connect(transport, { timeout: 20 })).rejects.toMatchObject({
code: SdkErrorCode.RequestTimeout
});

expect(transport.initializedSignal?.aborted).toBe(true);
expect(transport.closeCalls).toBe(1);
expect(client.transport).toBeUndefined();
});

test.each([
new Error('connect cancelled'),
new SdkError(SdkErrorCode.ConnectionClosed, 'caller closed connection'),
new DOMException('Aborted', 'AbortError')
])('preserves the caller abort reason while sending initialized: %s', async reason => {
const transport = new HangingInitializedTransport();
const client = createClient();
const controller = new AbortController();

const connecting = client.connect(transport, { timeout: 1_000, signal: controller.signal });
await transport.initializedSentPromise;
controller.abort(reason);

await expect(connecting).rejects.toBe(reason);
expect(transport.initializedSignal?.reason).toBe(reason);
expect(transport.initializedSignal?.aborted).toBe(true);
expect(transport.closeCalls).toBe(1);
expect(client.transport).toBeUndefined();
});

test('records the current cross-stage abort boundary; update alongside request() cancellation semantics', async () => {
// This records the current boundary, not a requirement to keep request() cancellation semantics unchanged.
const reason = new Error('user cancel');
for (const hangInitialize of [true, false]) {
const transport = new HangingInitializedTransport(true, 0, undefined, hangInitialize);
const client = createClient();
const controller = new AbortController();
const connecting = client.connect(transport, { timeout: 1_000, signal: controller.signal });
const rejected = connecting.catch((error: unknown) => error);

await (hangInitialize ? transport.initializeSentPromise : transport.initializedSentPromise);
controller.abort(reason);

if (hangInitialize) {
const error = await rejected;
expect(error).toBeInstanceOf(SdkError);
expect(error).toMatchObject({ code: SdkErrorCode.RequestTimeout });
expect(error).not.toBe(reason);
} else {
await expect(connecting).rejects.toBe(reason);
}
}
});

test('gives initialized a fresh timeout after initialize uses part of its budget', async () => {
vi.useFakeTimers();
const transport = new HangingInitializedTransport(false, 80);
const client = createClient();
let settled = false;
const connecting = client.connect(transport, { timeout: 100 });
const rejected = expect(connecting).rejects.toMatchObject({ code: SdkErrorCode.RequestTimeout });
void connecting.then(
() => {
settled = true;
},
() => {
settled = true;
}
);

await vi.advanceTimersByTimeAsync(80);
await transport.initializedSentPromise;
await vi.advanceTimersByTimeAsync(99);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await rejected;
expect(transport.closeCalls).toBe(1);
});

test.each([
new Error('connect cancelled before initialized'),
new SdkError(SdkErrorCode.ConnectionClosed, 'caller closed connection'),
'cancelled'
])('preserves an abort reason before initialized and closes the transport: %s', async reason => {
const controller = new AbortController();
const transport = new HangingInitializedTransport(true, 0, () => controller.abort(reason));
const client = createClient();

await expect(client.connect(transport, { signal: controller.signal })).rejects.toBe(reason);
expect(transport.initializedSignal).toBeUndefined();
expect(transport.closeCalls).toBe(1);
expect(client.transport).toBeUndefined();
});
});
6 changes: 6 additions & 0 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ export type NotificationOptions = {
* May be used to indicate to the transport which incoming request to associate this outgoing notification with.
*/
relatedRequestId?: RequestId;

/**
* Aborts the underlying per-message request when the transport supports
* request-level cancellation (for example, a Streamable HTTP POST).
*/
requestSignal?: AbortSignal;
};

/**
Expand Down
Loading