Skip to content

Commit e79bf92

Browse files
[SEP-2575] client: connect() auto-probe activates _isStateless
connect() now probes server/discover via transport.sendAndReceive before the legacy initialize handshake. On success the client enters stateless mode (server identity/capabilities from DiscoverResult, initialize skipped). On MethodNotFound / HTTP 4xx / parse failure it falls through to the legacy initialize (extracted verbatim into _initialize()). _setupListChanged() routes options.listChanged to _listChangedLoop (subscriptions/listen) when stateless, else to the existing notification-handler path. Existing tests that exercise pre-2026 connection-model behavior now need LegacyTestClient (C14).
1 parent f163ad4 commit e79bf92

3 files changed

Lines changed: 190 additions & 76 deletions

File tree

packages/client/src/client/client.ts

Lines changed: 59 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,24 @@ type ListChangedKinds = Record<
9292

9393
/**
9494
* Returns true for `server/discover` failures that should fall through to the
95-
* legacy `initialize` handshake (server doesn't speak 2026-06).
95+
* legacy `initialize` handshake (server doesn't speak 2026-06). Auth failures
96+
* (401/403) are NOT fallbackable: a server that requires auth for `discover`
97+
* will require it for `initialize` too, so falling back would only mask the
98+
* real error and skip the transport's re-auth path.
9699
*/
97100
function isFallbackable(e: unknown): boolean {
98101
if (e instanceof ProtocolError) {
99102
return e.code === ProtocolErrorCode.MethodNotFound;
100103
}
101104
if (e instanceof SdkError) {
102105
const status = (e.data as { status?: number } | undefined)?.status;
103-
return e.code === SdkErrorCode.InvalidResult || (typeof status === 'number' && status >= 400 && status < 500);
106+
// Any 4xx except 401/403 (auth) means the server doesn't speak 2026-06.
107+
// 400 in particular is what a pre-2026 StreamableHTTP server returns for
108+
// a non-initialize POST without an mcp-session-id.
109+
return (
110+
e.code === SdkErrorCode.InvalidResult ||
111+
(typeof status === 'number' && status >= 400 && status < 500 && status !== 401 && status !== 403)
112+
);
104113
}
105114
return false;
106115
}
@@ -475,33 +484,53 @@ export class Client extends Protocol<ClientContext> {
475484
* Probes `server/discover` via `transport.sendAndReceive`. On success,
476485
* marks this client stateless and populates server identity/capabilities
477486
* from the result. On {@linkcode isFallbackable} failure, leaves state
478-
* untouched (the legacy `initialize` already populated it via `connect()`).
479-
*
480-
* Called from {@linkcode connect} (in C13).
487+
* untouched so {@linkcode connect} falls through to the legacy
488+
* `initialize` handshake.
481489
*/
482-
private async _negotiate(transport: Transport): Promise<void> {
490+
private async _negotiate(transport: Transport, options?: RequestOptions): Promise<void> {
483491
const sar = transport.sendAndReceive?.bind(transport);
484492
const preferred = this._supportedProtocolVersions.find(v => isStatelessProtocolVersion(v));
485493
if (!sar || !preferred) return;
486494

487495
transport.setProtocolVersion?.(preferred);
496+
const signal =
497+
options?.timeout === undefined
498+
? (options?.signal ?? AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MSEC))
499+
: options?.signal
500+
? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeout)])
501+
: AbortSignal.timeout(options.timeout);
488502
try {
489-
const raw = await this._collect(sar({ method: 'server/discover', params: { _meta: this._buildMeta(preferred) } }));
490-
const dr = DiscoverResultSchema.parse(raw);
491-
const negotiated = dr.supportedVersions.find(v => this._supportedProtocolVersions.includes(v));
492-
if (negotiated && isStatelessProtocolVersion(negotiated)) {
493-
this._serverCapabilities = dr.capabilities;
494-
this._serverVersion = dr.serverInfo;
495-
this._instructions = dr.instructions;
496-
this._negotiatedProtocolVersion = negotiated;
497-
this._isStateless = true;
498-
transport.setProtocolVersion?.(negotiated);
499-
return;
503+
const raw = await this._collect(sar({ method: 'server/discover', params: { _meta: this._buildMeta(preferred) } }, { signal }), {
504+
signal
505+
});
506+
const drParsed = DiscoverResultSchema.safeParse(raw);
507+
if (drParsed.success) {
508+
const dr = drParsed.data;
509+
// The probe only counts as success when there is a mutual
510+
// *stateless* version; otherwise fall through to legacy initialize.
511+
const negotiated = dr.supportedVersions.find(
512+
v => isStatelessProtocolVersion(v) && this._supportedProtocolVersions.includes(v)
513+
);
514+
if (negotiated) {
515+
this._serverCapabilities = dr.capabilities;
516+
this._serverVersion = dr.serverInfo;
517+
this._instructions = dr.instructions;
518+
this._negotiatedProtocolVersion = negotiated;
519+
this._isStateless = true;
520+
transport.setProtocolVersion?.(negotiated);
521+
return;
522+
}
500523
}
501524
} catch (error) {
502-
if (!isFallbackable(error)) throw error;
525+
if (!isFallbackable(error)) {
526+
// Reset the version we set before re-throwing so the
527+
// transport is not left advertising a stateless version.
528+
transport.setProtocolVersion?.(this._negotiatedProtocolVersion ?? '');
529+
throw error;
530+
}
503531
}
504-
// Reset header to whatever legacy initialize set.
532+
// Fallback path: reset the version header so the subsequent legacy
533+
// `_initialize()` (run by `connect()`) can set it.
505534
transport.setProtocolVersion?.(this._negotiatedProtocolVersion ?? '');
506535
}
507536

@@ -609,10 +638,11 @@ export class Client extends Protocol<ClientContext> {
609638
// ═══════════════════════════════════════════════════════════════════════
610639
// session-dependent (existing — bodies unchanged unless noted dual-mode above)
611640
//
612-
// `connect()` performs the legacy `initialize` handshake. The 2026-06
613-
// discover auto-probe is wired in C13. `ping`, `subscribeResource`,
614-
// `unsubscribeResource`, and `_setupListChangedHandler*` use the
615-
// persistent connection; `_listChangedLoop` (above) is the 2026 path.
641+
// `_initialize()` (extracted verbatim from the previous inline `connect()`
642+
// body) performs the legacy `initialize` handshake. `ping`,
643+
// `subscribeResource`, `unsubscribeResource`, and
644+
// `_setupListChangedHandler*` use the persistent connection;
645+
// `_listChangedLoop` (above) is the 2026 path.
616646
// ═══════════════════════════════════════════════════════════════════════
617647

618648
/**
@@ -787,46 +817,13 @@ export class Client extends Protocol<ClientContext> {
787817
return;
788818
}
789819
try {
790-
const result = await this._requestWithSchema(
791-
{
792-
method: 'initialize',
793-
params: {
794-
protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION,
795-
capabilities: this._capabilities,
796-
clientInfo: this._clientInfo
797-
}
798-
},
799-
InitializeResultSchema,
800-
options
801-
);
802-
803-
if (result === undefined) {
804-
throw new Error(`Server sent invalid initialize result: ${result}`);
805-
}
806-
807-
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
808-
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
809-
}
810-
811-
this._serverCapabilities = result.capabilities;
812-
this._serverVersion = result.serverInfo;
813-
this._negotiatedProtocolVersion = result.protocolVersion;
814-
// HTTP transports must set the protocol version in each header after initialization.
815-
if (transport.setProtocolVersion) {
816-
transport.setProtocolVersion(result.protocolVersion);
817-
}
818-
819-
this._instructions = result.instructions;
820-
821-
await this.notification({
822-
method: 'notifications/initialized'
823-
});
824-
825-
// Set up list changed handlers now that we know server capabilities
826-
if (this._pendingListChangedConfig) {
827-
this._setupListChangedHandlers(this._pendingListChangedConfig);
828-
this._pendingListChangedConfig = undefined;
820+
// Probe `server/discover` (SEP-2575). If it succeeds, this client is
821+
// stateless and the legacy `initialize` is skipped.
822+
await this._negotiate(transport, options);
823+
if (!this._isStateless) {
824+
await this._initialize(transport, options);
829825
}
826+
this._setupListChanged();
830827
} catch (error) {
831828
// Disconnect if initialization fails.
832829
void this.close();

packages/client/src/client/streamableHttp.ts

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -195,32 +195,66 @@ export class StreamableHTTPClientTransport implements Transport {
195195
* `subscriptions/listen` the indefinite stream). Backed directly by
196196
* `fetch`; does not go through `Protocol.request()`/`_responseHandlers`.
197197
*
198-
* Used by `Client` for 2026-06 stateless calls. Auth handling matches
199-
* {@linkcode send} (token attached via `_commonHeaders`); 401/403 retry
200-
* is left to the caller (`Client` falls back to legacy `request()` on
201-
* auth failure).
198+
* Used by `Client` for 2026-06 stateless calls. Auth handling mirrors
199+
* {@linkcode send}: token attached via `_commonHeaders`, one 401 retry via
200+
* `authProvider.onUnauthorized`, and one 403 `insufficient_scope` upscoping
201+
* retry for OAuth providers. The ladder is duplicated here (not shared with
202+
* `send()`) so `send()` stays byte-identical to the pre-2026 code path.
202203
*/
203204
async *sendAndReceive(
204205
request: Omit<JSONRPCRequest, 'jsonrpc' | 'id'>,
205206
opts?: { signal?: AbortSignal }
206207
): AsyncGenerator<JSONRPCMessage, void, void> {
207-
const headers = await this._commonHeaders();
208-
headers.set('content-type', 'application/json');
209-
headers.set('accept', 'application/json, text/event-stream');
210208
const body = JSON.stringify({ jsonrpc: '2.0', id: 0, ...request });
211209
const signal =
212210
opts?.signal && this._abortController
213211
? AbortSignal.any([opts.signal, this._abortController.signal])
214212
: (opts?.signal ?? this._abortController?.signal);
215-
const response = await (this._fetch ?? fetch)(this._url, {
216-
...this._requestInit,
217-
method: 'POST',
218-
headers,
219-
body,
220-
signal
221-
});
213+
const post = async (): Promise<Response> => {
214+
const headers = await this._commonHeaders();
215+
headers.set('content-type', 'application/json');
216+
headers.set('accept', 'application/json, text/event-stream');
217+
return (this._fetch ?? fetch)(this._url, { ...this._requestInit, method: 'POST', headers, body, signal });
218+
};
219+
let response = await post();
220+
if (response.status === 401 && this._authProvider) {
221+
if (response.headers.has('www-authenticate')) {
222+
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
223+
this._resourceMetadataUrl = resourceMetadataUrl;
224+
this._scope = scope;
225+
}
226+
if (this._authProvider.onUnauthorized) {
227+
await this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit });
228+
await response.text?.().catch(() => {});
229+
response = await post();
230+
}
231+
}
232+
if (response.status === 403 && this._oauthProvider) {
233+
const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
234+
if (error === 'insufficient_scope') {
235+
const wwwAuthHeader = response.headers.get('WWW-Authenticate');
236+
if (this._lastUpscopingHeader !== wwwAuthHeader) {
237+
if (scope) this._scope = scope;
238+
if (resourceMetadataUrl) this._resourceMetadataUrl = resourceMetadataUrl;
239+
this._lastUpscopingHeader = wwwAuthHeader ?? undefined;
240+
const result = await auth(this._oauthProvider, {
241+
serverUrl: this._url,
242+
resourceMetadataUrl: this._resourceMetadataUrl,
243+
scope: this._scope,
244+
fetchFn: this._fetchWithInit
245+
});
246+
if (result === 'AUTHORIZED') {
247+
await response.text?.().catch(() => {});
248+
response = await post();
249+
}
250+
}
251+
}
252+
}
222253
if (!response.ok) {
223254
const text = await response.text().catch(() => '');
255+
if (response.status === 401) {
256+
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, text || 'Unauthorized', { status: 401 });
257+
}
224258
throw new SdkError(SdkErrorCode.SendFailed, `HTTP ${response.status}: ${text || response.statusText}`, {
225259
status: response.status
226260
});

packages/client/test/client/clientSend.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,86 @@ describe('Client.subscribe', () => {
172172
expect(seen).toEqual(['notifications/subscriptions/acknowledged', 'notifications/tools/list_changed']);
173173
});
174174
});
175+
176+
describe('Client.connect auto-probe (SEP-2575)', () => {
177+
function discoverable(handler: (req: JSONRPCRequest) => AsyncIterable<JSONRPCMessage>): Transport {
178+
const t = mockTransport(handler);
179+
// Route legacy `initialize` (sent via Protocol.request → transport.send)
180+
// back through onmessage so the fallback path can complete in-process.
181+
t.send = async m => {
182+
if ('method' in m && m.method === 'initialize') {
183+
queueMicrotask(() =>
184+
t.onmessage?.({
185+
jsonrpc: JSONRPC_VERSION,
186+
id: (m as JSONRPCRequest).id,
187+
result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 's', version: '1' } }
188+
})
189+
);
190+
} else if ('method' in m && m.method === 'notifications/initialized') {
191+
// ignore
192+
}
193+
};
194+
return t;
195+
}
196+
197+
it('discover success → stateless mode, skips initialize', async () => {
198+
const seen: string[] = [];
199+
const t = discoverable(req => {
200+
seen.push(req.method);
201+
return once({
202+
jsonrpc: JSONRPC_VERSION,
203+
id: req.id,
204+
result: {
205+
supportedVersions: [DRAFT_PROTOCOL_VERSION],
206+
capabilities: { tools: {} },
207+
serverInfo: { name: 's', version: '2' }
208+
}
209+
});
210+
});
211+
const c = new Client({ name: 'c', version: '1' });
212+
await c.connect(t);
213+
expect(seen).toEqual(['server/discover']);
214+
expect((c as unknown as { _isStateless: boolean })._isStateless).toBe(true);
215+
expect(c.getServerCapabilities()).toEqual({ tools: {} });
216+
expect(c.getServerVersion()).toEqual({ name: 's', version: '2' });
217+
});
218+
219+
it('discover MethodNotFound → falls back to legacy initialize', async () => {
220+
const seen: string[] = [];
221+
const t = discoverable(req => {
222+
seen.push(req.method);
223+
return once({ jsonrpc: JSONRPC_VERSION, id: req.id, error: { code: -32_601, message: 'unknown method' } });
224+
});
225+
const c = new Client({ name: 'c', version: '1' });
226+
await c.connect(t);
227+
expect(seen).toEqual(['server/discover']);
228+
expect((c as unknown as { _isStateless: boolean })._isStateless).toBe(false);
229+
expect(c.getServerVersion()).toEqual({ name: 's', version: '1' });
230+
});
231+
232+
it('no sendAndReceive → goes straight to legacy initialize', async () => {
233+
const t: Transport = {
234+
start: async () => {},
235+
close: async () => {},
236+
send: async m => {
237+
if ('method' in m && m.method === 'initialize') {
238+
queueMicrotask(() =>
239+
t.onmessage?.({
240+
jsonrpc: JSONRPC_VERSION,
241+
id: (m as JSONRPCRequest).id,
242+
result: {
243+
protocolVersion: '2025-11-25',
244+
capabilities: {},
245+
serverInfo: { name: 's', version: '1' }
246+
}
247+
})
248+
);
249+
}
250+
}
251+
};
252+
const c = new Client({ name: 'c', version: '1' });
253+
await c.connect(t);
254+
expect((c as unknown as { _isStateless: boolean })._isStateless).toBe(false);
255+
expect(c.getServerVersion()?.name).toBe('s');
256+
});
257+
});

0 commit comments

Comments
 (0)