Skip to content
Open
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
53 changes: 43 additions & 10 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@ export class StreamableHTTPClientTransport implements Transport {
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
private readonly _reconnectionScheduler?: ReconnectionScheduler;
private _cancelReconnection?: () => void;
private _pendingAuthPromise?: Promise<void>;
private _authReject?: (error: Error) => void;

onclose?: () => void;
onerror?: (error: Error) => void;
Expand Down Expand Up @@ -499,6 +501,9 @@ export class StreamableHTTPClientTransport implements Transport {
}

private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false, stepUpRetries = 0): Promise<void> {
if (this._pendingAuthPromise) {
await this._pendingAuthPromise;
}
const { resumptionToken, requestSignal } = options;
// Same guard as `_handleSseStream`: a resurrected listen stream (the
// POST-SSE → GET reconnect path threads `requestSignal` through
Expand Down Expand Up @@ -867,17 +872,35 @@ export class StreamableHTTPClientTransport implements Transport {
{ fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl }
);

const result = await auth(this._oauthProvider, {
serverUrl: this._url,
authorizationCode,
iss: issParam,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit,
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
let authResolve: () => void;
this._pendingAuthPromise = new Promise<void>((resolve, reject) => {
authResolve = resolve;
this._authReject = reject;
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError('Failed to authorize');
// Prevent UnhandledPromiseRejection if it fails and no one awaits it
this._pendingAuthPromise.catch(() => {});

try {
const result = await auth(this._oauthProvider, {
serverUrl: this._url,
authorizationCode,
iss: issParam,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit,
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError('Failed to authorize');
}
authResolve!();
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this._authReject?.(err);
throw error;
} finally {
this._pendingAuthPromise = undefined;
this._authReject = undefined;
}
}

Expand All @@ -887,6 +910,13 @@ export class StreamableHTTPClientTransport implements Transport {
} finally {
this._cancelReconnection = undefined;
this._abortController?.abort();

if (this._authReject) {
this._authReject(new Error('Transport closed'));
this._pendingAuthPromise = undefined;
this._authReject = undefined;
}

this.onclose?.();
}
}
Expand Down Expand Up @@ -918,6 +948,9 @@ export class StreamableHTTPClientTransport implements Transport {
isAuthRetry: boolean,
stepUpRetries = 0
): Promise<void> {
if (this._pendingAuthPromise) {
await this._pendingAuthPromise;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the triggering request pending across REDIRECT

This await only observes a promise that finishAuth() creates later. In #2510's mid-session path, _send() is already inside onUnauthorized; handleOAuthUnauthorized() receives REDIRECT and throws UnauthorizedError, so the original send() rejects before the browser callback can call finishAuth() or create this promise. On this exact head, the isolated existing test uses custom fetch during auth flow on 401 - no global fetch fallback still passes specifically by expecting that rejection, and this PR changes no tests. Thus the code may exchange a callback code later, but it still cannot resume and retry the triggering request. Please establish the deferred when REDIRECT is produced, leave the triggering request pending, resolve/reject it from finishAuth()/close(), retry the request, and cover that full sequence.

}
try {
const { resumptionToken, onresumptiontoken } = options || {};

Expand Down
Loading