Skip to content

Commit cc70c5e

Browse files
fix(client): preserve pre-set transport handlers across the version-negotiation probe window (#2455)
1 parent 0ab5d14 commit cc70c5e

3 files changed

Lines changed: 154 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Version negotiation no longer discards transport handlers the caller set before `connect()`. The probe window now saves any pre-set `onmessage`/`onerror`/`onclose`, forwards error and close events to them while the probe is in flight, and restores them when the window closes — so `Protocol.connect()` chains them exactly as it does on a plain connect. Previously, connecting with `versionNegotiation` silently cleared pre-set handlers (e.g. an `onerror` used to detect session-expiry auth failures), leaving them permanently detached for the life of the connection.

packages/client/src/client/versionNegotiation.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,30 @@ type RawProbeReply =
171171
* starts the transport; `release()` detaches them and arms a one-shot `start()`
172172
* pass-through so the subsequent Protocol connect (which always starts its
173173
* transport) takes over the already-started channel without a double-start error.
174+
*
175+
* Handlers a caller pre-set on the transport before `connect()` are saved on
176+
* open and restored on detach, so `Protocol.connect()` finds and chains them
177+
* exactly as it would on a plain (non-negotiating) connect. Error and close
178+
* events are forwarded to the saved handlers during the window, so negotiation
179+
* does not change what a pre-attached observer sees of the transport
180+
* lifecycle. (Inbound messages are deliberately NOT forwarded — the window's
181+
* drop-guard is a module invariant, and the probe reply has no plain-connect
182+
* counterpart; a pre-set `onmessage` is restored at detach and chained by
183+
* `Protocol.connect()` like the others.)
174184
*/
175185
class ProbeWindow {
176186
private _pending: { id: string; resolve: (reply: RawProbeReply) => void } | undefined;
177187
private _probeCounter = 0;
188+
private readonly _savedOnMessage: Transport['onmessage'];
189+
private readonly _savedOnError: Transport['onerror'];
190+
private readonly _savedOnClose: Transport['onclose'];
191+
private _closeDelivered = false;
178192

179-
private constructor(private readonly _transport: Transport) {}
193+
private constructor(private readonly _transport: Transport) {
194+
this._savedOnMessage = _transport.onmessage;
195+
this._savedOnError = _transport.onerror;
196+
this._savedOnClose = _transport.onclose;
197+
}
180198

181199
static async open(transport: Transport): Promise<ProbeWindow> {
182200
const window = new ProbeWindow(transport);
@@ -197,18 +215,29 @@ class ProbeWindow {
197215
}
198216
// Probe-window guard: drop everything else with zero bytes written back (see module doc).
199217
};
200-
transport.onerror = () => {
218+
transport.onerror = error => {
201219
// Out-of-band transport errors are not necessarily fatal; the probe
202220
// resolves via a send failure, the close signal, or the timeout.
221+
window._savedOnError?.(error);
203222
};
204223
transport.onclose = () => {
205224
const pending = window._pending;
206225
if (pending !== undefined) {
207226
window._pending = undefined;
208227
pending.resolve({ kind: 'closed' });
209228
}
229+
// Forward exactly once: after a mid-window close is delivered, the
230+
// restored handler must not re-deliver it when cleanup paths call
231+
// `transport.close()` again.
232+
window._closeDelivered = true;
233+
window._savedOnClose?.();
210234
};
211-
await transport.start();
235+
try {
236+
await transport.start();
237+
} catch (error) {
238+
window.detach();
239+
throw error;
240+
}
212241
return window;
213242
}
214243

@@ -235,12 +264,12 @@ class ProbeWindow {
235264
});
236265
}
237266

238-
/** Detach the window's handlers, leaving the transport's own `start` untouched. */
267+
/** Detach the window's handlers, restoring any the caller pre-set, leaving the transport's own `start` untouched. */
239268
detach(): void {
240269
this._pending = undefined;
241-
this._transport.onmessage = undefined;
242-
this._transport.onerror = undefined;
243-
this._transport.onclose = undefined;
270+
this._transport.onmessage = this._savedOnMessage;
271+
this._transport.onerror = this._savedOnError;
272+
this._transport.onclose = this._closeDelivered ? undefined : this._savedOnClose;
244273
}
245274

246275
/** Detach the handlers and arm the one-shot `start()` pass-through for the `Protocol.connect()` handover. */

packages/client/test/client/versionNegotiation.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,3 +782,116 @@ describe('probe send-error classification', () => {
782782
expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false);
783783
});
784784
});
785+
786+
/* ------------------------------------------------------------------------- *
787+
* Probe window handler preservation: handlers pre-set on the transport
788+
* before connect() survive negotiation exactly as they survive a plain
789+
* connect — Protocol.connect() must find and chain them after the window.
790+
* ------------------------------------------------------------------------- */
791+
792+
describe('probe window preserves pre-set transport handlers', () => {
793+
test('pre-set onerror/onclose are restored after a modern negotiation and reachable through the Protocol chain', async () => {
794+
const transport = new ScriptedTransport(modernServerScript());
795+
const seenErrors: Error[] = [];
796+
let closed = 0;
797+
transport.onerror = error => {
798+
seenErrors.push(error);
799+
};
800+
transport.onclose = () => {
801+
closed++;
802+
};
803+
804+
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
805+
await client.connect(transport);
806+
807+
// The window restored the handler, so Protocol.connect chained it:
808+
// post-connect transport errors still reach the pre-set observer.
809+
const boom = new Error('post-connect transport error');
810+
transport.onerror?.(boom);
811+
expect(seenErrors).toContain(boom);
812+
813+
await client.close();
814+
expect(closed).toBeGreaterThan(0);
815+
});
816+
817+
test('pre-set onerror survives the legacy fallback path too', async () => {
818+
const transport = new ScriptedTransport(legacyServerScript);
819+
const seenErrors: Error[] = [];
820+
transport.onerror = error => {
821+
seenErrors.push(error);
822+
};
823+
824+
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
825+
await client.connect(transport);
826+
expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25');
827+
828+
const boom = new Error('post-fallback transport error');
829+
transport.onerror?.(boom);
830+
expect(seenErrors).toContain(boom);
831+
832+
await client.close();
833+
});
834+
835+
test('failed negotiation (pin mode, no fallback) restores handlers via the detach path — onclose fires exactly once', async () => {
836+
const transport = new ScriptedTransport(legacyServerScript);
837+
const presetOnError = (_error: Error) => {};
838+
let closes = 0;
839+
transport.onerror = presetOnError;
840+
transport.onclose = () => {
841+
closes++;
842+
};
843+
844+
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } });
845+
await expect(client.connect(transport)).rejects.toThrow();
846+
847+
// detach() restored the pre-set handlers, and Client's cleanup close
848+
// delivered the close event exactly once.
849+
expect(transport.onerror).toBe(presetOnError);
850+
expect(closes).toBe(1);
851+
});
852+
853+
test('a mid-probe transport close reaches the pre-set onclose exactly once (no re-delivery from cleanup close)', async () => {
854+
const script: Script = (message, t) => {
855+
if (!isJSONRPCRequest(message)) return;
856+
if (message.method === 'server/discover') {
857+
t.onclose?.();
858+
return;
859+
}
860+
legacyServerScript(message, t);
861+
};
862+
const transport = new ScriptedTransport(script);
863+
let closes = 0;
864+
transport.onclose = () => {
865+
closes++;
866+
};
867+
868+
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
869+
await client.connect(transport).catch(() => undefined);
870+
871+
expect(closes).toBe(1);
872+
});
873+
874+
test('transport errors DURING the probe window are forwarded to the pre-set handler', async () => {
875+
const duringProbe = new Error('mid-probe transport error');
876+
const script: Script = (message, t) => {
877+
if (!isJSONRPCRequest(message)) return;
878+
if (message.method === 'server/discover') {
879+
t.onerror?.(duringProbe);
880+
t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } });
881+
return;
882+
}
883+
legacyServerScript(message, t);
884+
};
885+
const transport = new ScriptedTransport(script);
886+
const seenErrors: Error[] = [];
887+
transport.onerror = error => {
888+
seenErrors.push(error);
889+
};
890+
891+
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
892+
await client.connect(transport);
893+
894+
expect(seenErrors).toContain(duringProbe);
895+
await client.close();
896+
});
897+
});

0 commit comments

Comments
 (0)