-
Notifications
You must be signed in to change notification settings - Fork 2.2k
fix(client): retry the SSE connection once after onUnauthorized #2905
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': patch | ||
| --- | ||
|
|
||
| `SSEClientTransport` now retries the SSE connection once after `onUnauthorized()` resolves, as documented. If the retry is also answered with 401, `start()` rejects with `SdkHttpError` (`ClientHttpAuthentication`) instead of calling `onUnauthorized()` again. A 401 on a later reconnect of a stream that had opened still gets one refresh. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -175,6 +175,8 @@ export class SSEClientTransport implements Transport { | |
| } | ||
|
|
||
| private _last401Response?: Response; | ||
| // True between a 401-triggered reconnect and the next successful open. | ||
| private _connectAuthRetried = false; | ||
|
|
||
| private async _commonHeaders(): Promise<Headers> { | ||
| // Start from the caller-supplied `requestInit.headers` and `set()` the | ||
|
|
@@ -239,9 +241,10 @@ export class SSEClientTransport implements Transport { | |
|
|
||
| this._eventSource.onerror = event => { | ||
| if (event.code === 401 && this._authProvider) { | ||
| if (this._authProvider.onUnauthorized && this._last401Response) { | ||
| if (this._authProvider.onUnauthorized && this._last401Response && !this._connectAuthRetried) { | ||
| const response = this._last401Response; | ||
| this._last401Response = undefined; | ||
| this._connectAuthRetried = true; | ||
| this._eventSource?.close(); | ||
| this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then( | ||
| // onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject. | ||
|
|
@@ -250,14 +253,25 @@ export class SSEClientTransport implements Transport { | |
| // stamp: covers the SDK's OAuth flow and custom | ||
| // callbacks alike. | ||
| (error: unknown) => { | ||
| this._connectAuthRetried = false; | ||
| markAuthSeamEscape(error); | ||
| this.onerror?.(error as Error); | ||
| reject(error); | ||
| } | ||
| ); | ||
| return; | ||
| } | ||
| const error = markAuthSeamEscape(new UnauthorizedError()); | ||
| const retried = this._connectAuthRetried; | ||
| this._connectAuthRetried = false; | ||
| const error = markAuthSeamEscape( | ||
| retried | ||
| ? new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { | ||
| status: 401, | ||
| statusText: this._last401Response?.statusText ?? '' | ||
| }) | ||
| : new UnauthorizedError() | ||
|
Comment on lines
+264
to
+272
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): readers of the Why this was flaggedA caller with an AuthProvider that has onUnauthorized connects via SSEClientTransport.start() against a server that answers 401 both before and after the refresh. packages/client/src/client/sse.ts:264-273 now rejects with SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication'); on the base branch this path looped by calling onUnauthorized again and never rejected. The public JSDoc in the same file, sse.ts:73-77, still promises "If the retry also gets 401, or Verification: nit. Triggering condition: a reader relies on the public |
||
| ); | ||
| this._last401Response = undefined; | ||
| reject(error); | ||
| this.onerror?.(error); | ||
| return; | ||
|
Comment on lines
253
to
277
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 A connected client whose post-refresh reconnect hits a network outage longer than its new token's lifetime now loses the stream with a terminal auth error instead of refreshing once more. The Why this was flaggedTrigger: the stream has opened (flag cleared at sse.ts:287), later drops and EventSource auto-reconnects; the server answers 401 because the token expired. sse.ts:244 passes, sse.ts:247 sets Verification: normal — triggered when, after an opened stream drops and the auto-reconnect gets a 401 that is refreshed, the refreshed EventSource's fetch fails at the network level and the token is no longer valid by the time the library's own reconnect loop reaches the server again (server restart/deploy with key rotation, or an outage longer than a short-lived token).
Comment on lines
+264
to
277
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: pre-existing, widened here: after the stream had opened, a post-refresh 401 on an automatic reconnect leaves the transport dead but never fires Why this was flaggedTrigger: an SSE stream that opened (start() resolved at packages/client/src/client/sse.ts:306) drops; the eventsource auto-reconnect gets 401, the transport refreshes and reconnects (sse.ts:244-251), and the new EventSource is answered 401 again — the always-401-after-refresh case this PR targets, now on a live connection rather than during start(). The retried arm at sse.ts:264-277 builds SdkHttpError, calls reject (the start() promise already resolved, so this does nothing) and this.onerror, and returns. The eventsource library fails the connection on a non-200 status, so no further reconnect happens and no other code path closes the transport. Protocol only settles response handlers and marks the connection closed from transport.onclose (packages/core-internal/src/shared/protocol.ts:797-803, 836); transport.onerror just forwards to the user (protocol.ts:806-809, 867-869). Verification: pre-existing — triggered when an SSE stream that had opened (start() resolved at packages/client/src/client/sse.ts:306) drops, the eventsource auto-reconnect is answered 401, the transport refreshes and reconnects (sse.ts:244-251), and the fresh EventSource is answered 401 again. |
||
|
|
@@ -270,6 +284,7 @@ export class SSEClientTransport implements Transport { | |
|
|
||
| this._eventSource.onopen = () => { | ||
| // The connection is open, but we need to wait for the endpoint to be received. | ||
| this._connectAuthRetried = false; | ||
|
Comment on lines
285
to
+287
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: Clients connecting to a server that answers 200 and drops the stream before sending Why this was flaggedTrigger: during start(), the first GET returns 401, onUnauthorized resolves, the retry GET returns 200 text/event-stream but the server (or a proxy/load balancer whose backends disagree on the rotated token) closes the response before the endpoint event; eventsource auto-reconnects and the next GET returns 401. Entry point is SSEClientTransport.start() via Client.connect. At packages/client/src/client/sse.ts:285-288 onopen sets _connectAuthRetried = false on the 200, before endpoint arrives and before resolve() at sse.ts:306. The reconnect's 401 reaches sse.ts:244 with the flag false, so onUnauthorized is called again, the flag is set, another EventSource is opened, and the cycle repeats. The caller's start() promise never settles and the token endpoint is hit once per cycle; on base the loop is identical, so the PR does not fix #2894 for this population, while the changeset (.changeset/sse-connect-retry-once.md:5) promises retry-once. The dismissal accepted the author's intent (reset on open) instead of checking that open is not connect success for this transport. Verification: pre-existing — the base branch already loops the same way by the same route (it had no guard at all), and this PR's guard does not reach this path. Triggering condition: during start(), a server (external input) answers 401, then 200 text/event-stream but closes the body before sending the |
||
| }; | ||
|
|
||
| this._eventSource.addEventListener('endpoint', (event: Event) => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 nit (optional): CLAUDE.md asks for 2-space indentation: every line this PR adds to packages/client/src/client/sse.ts (e.g. the new
_connectAuthRetriedfield at line 179 and the 401 branch at lines 244-275) and to test/client/sse.test.ts is indented with 4 spaces. Fix: the repo's committed.prettierrc.jsonsetstabWidth: 4andpnpm lint:allenforces it, so the practical fix is to correct the stale CLAUDE.md line to say 4-space indentation rather than reindent the code; if 2 spaces is really intended, the prettier config must change first so lint and CLAUDE.md agree.Why this was flagged
Nothing fails at runtime. The instruction guards consistent formatting; here the written rule (2 spaces) and the enforced formatter (
.prettierrc.json:tabWidth: 4,useTabs: false) disagree, and the whole existing file already uses 4 spaces, so the added lines match lint but not the CLAUDE.md text. Reindenting the new lines to 2 spaces would failpnpm lint:all; the only consequence of leaving it is that CLAUDE.md keeps mis-describing the repo's style. Low weight - flagged only because the instruction is written as it is.Verification: Base CLAUDE.md (git show aca8ba3:CLAUDE.md, "Code Style Guidelines") reads verbatim "- Formatting: 2-space indentation, semicolons required, single quotes preferred"; the diff adds
private _connectAuthRetried = false;at packages/client/src/client/sse.ts:179 with 4 leading spaces, and every other added line (e.g. theretried ? new SdkHttpError(...)branch at lines 264-274 andthis._connectAuthRetried = false;in onopen at line 287) is indented in multiples of 4, so the added code does not use 2-space indentation as the instruction is written.