Skip to content

fix(client): retry the SSE connection once after onUnauthorized - #2905

Merged
felixweinberger merged 2 commits into
mainfrom
fix/sse-connect-retry-once
Sep 30, 2026
Merged

felixweinberger merged 2 commits into
mainfrom
fix/sse-connect-retry-once

Conversation

@claude

@claude claude Bot commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Requested by Felix Weinberger · Slack thread

Motivation and Context

Before: against a server that keeps answering 401 while onUnauthorized() resolves, SSEClientTransport.start() reconnects without limit and never settles.

After: the first 401 refreshes and reconnects once; a second 401 before the stream opens rejects with SdkHttpError (ClientHttpAuthentication, "Server returned 401 after re-authentication"); the guard resets once the stream opens, so a later reconnect gets one refresh again. This matches the POST path and the Streamable HTTP transport, and adds no new option.

The two existing tests from #1710 that expected a second refresh are updated to the retry-once behaviour: SSE connect 401 retry does not poison future 401s — onUnauthorized called on each attempt (now … — a 401 on a later reconnect refreshes again) and retry failure during SSE connect fires onerror exactly once.

How: one private boolean in packages/client/src/client/sse.ts, set when a 401 triggers the reconnect and cleared on open.

How Has This Been Tested?

  • packages/client/test/client/sse.test.ts: new test for the always-401 server (rejects with SdkHttpError, onUnauthorized called once, two GETs, onerror once); the two tests above updated; a new test for a failing onUnauthorized during connect (onerror exactly once).
  • The file: 2 of 39 fail on main's sse.ts, 39 pass with the change.
  • pnpm --filter @modelcontextprotocol/client test (40 files, 939 passed), pnpm typecheck:all, pnpm lint:all all pass.
  • Changeset: @modelcontextprotocol/client patch.

Breaking Changes

None.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Fixes #2894

🤖 Generated with Claude Code

https://claude.ai/code/session_012VRbFCp41otcScXE1YY3es


Generated by Claude Code

felixweinberger and others added 2 commits September 30, 2026 13:33
SSEClientTransport reconnected after every 401 for as long as onUnauthorized() kept resolving, so start() never settled against a server that always answers 401. It now retries once, as documented and as the POST path and the Streamable HTTP transport do, and then rejects with SdkHttpError. The guard resets when the stream opens, so a 401 on a later reconnect still gets one refresh.
@claude
claude Bot requested a review from a team as a code owner September 30, 2026 13:44
@changeset-bot

changeset-bot Bot commented Sep 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 985573c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2905

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2905

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2905

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2905

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2905

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2905

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2905

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2905

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2905

commit: 985573c

@claude

claude Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

The failed pkg-publish check (pull_request run) died in the "Publish preview packages" step with a pkg.pr.new service error (HTTP 500, then 404 "no workflow defined") after the build completed; nothing in this PR's files is involved. The push-event run of the same workflow on this commit and the Continuous Releases check both passed, and the job passes on current main. The re-run of that job (attempt 2) passed.


Generated by Claude Code

@felixweinberger
felixweinberger merged commit c0cd01a into main Sep 30, 2026
20 of 21 checks passed
@felixweinberger
felixweinberger deleted the fix/sse-connect-retry-once branch September 30, 2026 13:53

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Beyond the inline findings, I also checked whether the statusText read at sse.ts:270 could be stale or empty on the post-refresh 401 — it is not, since the fetch wrapper at sse.ts:229 re-stashes _last401Response on every 401 response before the EventSource onerror fires.

Extended reasoning...

The change adds a single private boolean to the legacy SSE client transport's 401 handling in packages/client/src/client/sse.ts so that a second 401 after onUnauthorized rejects with SdkHttpError rather than looping, plus rewritten auth-retry tests and a patch changeset. It touches the client auth flow, and the inline findings cover the guard's interaction with non-401 errors, close() during a pending refresh, missing onclose on the terminal path, and an unbounded loop when the stream drops before the endpoint event, so a human look is warranted.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 packages/client/src/client/sse.ts — pre-existing: a caller that calls close() while onUnauthorized() is still pending gets a brand-new SSE connection opened afterwards, delivering messages to a transport it already closed. The resolve handler at sse.ts:251 calls _startOrAuth() without checking that the transport was closed, and close() at sse.ts:381-385 neither clears _eventSource nor rejects the pending start. Fix: have the deferred .then handlers (both the sse.ts:251 retry and the sse.ts:255 rejection path) check a closed flag or abort signal before touching this._eventSource/_abortController or starting I/O, and settle the pending start() promise instead of reconnecting. [also at: packages/client/src/client/sse.ts:256 - nit: REVIEW.md (Async / Lifecycle) requires deferred callbacks to check closed/aborted state before mutating this._*: the new onUnauthorized().then(..., error => { this._connectAuthRetried = false; ... }) rejection handler (and the _connectAuthRetried writes in the reconnect EventSource's…]

    Why this was flagged

    Trigger: start() gets a 401, sse.ts:249 invokes onUnauthorized (an OAuth token refresh that can take seconds), and during that wait the user calls close() (sse.ts:381), which aborts _abortController and closes the current EventSource. When onUnauthorized later resolves, sse.ts:251 runs _startOrAuth() unconditionally: sse.ts:218 creates a new EventSource with its own fetch (not bound to the aborted _abortController), sse.ts:240 replaces _abortController with a fresh one, and the stream connects and starts delivering onmessage events after onclose already fired. The pending start() then resolves after close(). This is pre-existing (base has the same sse.ts:251 line); the diff edits this callback pair (sse.ts:247, sse.ts:256) but adds no closed-state check.

    Verification: pre-existing — triggered when a caller invokes close() while the 401-triggered onUnauthorized() promise at packages/client/src/client/sse.ts:249 is still pending. Mechanism verified: the resolve handler at sse.ts:251 is () => this._startOrAuth().then(resolve, reject) with no closed/aborted check; close() at sse.ts:381-385 only does this._abortController?.abort(); this._eventSource?.close(); this.onclose?.() and sets no closed flag (grep for closed finds nothing in the file).

Comment on lines 253 to 277
// 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()
);
this._last401Response = undefined;
reject(error);
this.onerror?.(error);
return;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 _connectAuthRetried guard set at sse.ts:247 is only cleared by onopen or by a 401; a non-401 error at sse.ts:280 leaves it set while EventSource keeps auto-reconnecting on network errors, so the next 401 is blocked at sse.ts:244 and rejected at sse.ts:268 as 'Server returned 401 after re-authentication'. Fix: reset the guard whenever the post-refresh attempt fails for a non-401 reason (the sse.ts:280 path), so the guard means "the immediately preceding attempt was the retry" rather than "no open since the refresh", while an always-401 server still gets exactly one refresh per attempt.

Why this was flagged

Trigger: 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 _connectAuthRetried = true, onUnauthorized resolves with a new token and sse.ts:251 creates a new EventSource. That fetch fails with a network error (server restart or outage). eventsource ^3 (pnpm-workspace.yaml:39) dispatches an error event with no code and schedules a reconnect on network failures, so sse.ts:280 builds an SseError and nothing clears the flag. The EventSource keeps retrying every reconnect interval; once the outage outlasts the refreshed token's lifetime the server answers 401 again. sse.ts:244 is now false because of !this._connectAuthRetried, so sse.ts:264-276 rejects with SdkHttpError 'Server returned 401 after re-authentication' and calls onerror; eventsource closes on a non-200 status, and no onclose fires. On the base branch sse.ts:244 has no flag, so onUnauthorized runs again and the reconnect succeeds.

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 +272
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()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 nit (optional): readers of the authProvider JSDoc are told a second 401 throws UnauthorizedError, but after this change the connect path rejects with SdkHttpError instead. The doc at packages/client/src/client/sse.ts:75-77 says "If the retry also gets 401 ... UnauthorizedError is thrown", while sse.ts:268 builds SdkHttpError(ClientHttpAuthentication) on that exact path. Fix: update the JSDoc on SSEClientTransportOptions.authProvider (and the same sentence on AuthProvider.onUnauthorized in auth.ts:89-90) to say the post-refresh 401 rejects with SdkHttpError (ClientHttpAuthentication), keeping UnauthorizedError only for the no-onUnauthorized case. The POST path (sse.ts:435) already contradicted this sentence before the change; this diff extends the contradiction to start(). [also at: packages/client/src/client/sse.ts:77 - nit: Readers of the authProvider option docs are told a second 401 throws UnauthorizedError, but after this change the SSE connect retry rejects with SdkHttpError instead.; packages/client/src/client/sse.ts:268 - nit: Readers of the v2 migration guide are not told that SSEClientTransport.start() now rejects a post-refresh 401 with SdkHttpError instead of v1's UnauthorizedError.; +1 more]

Why this was flagged

A 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 onUnauthorized is not provided, UnauthorizedError is thrown", and auth.ts:88-90 repeats it for AuthProvider.onUnauthorized.

Verification: nit. Triggering condition: a reader relies on the public authProvider JSDoc for the error type after the single post-refresh retry over the SSE connect path. Mechanism verified: /home/claude/typescript-sdk/packages/client/src/client/sse.ts:75-77 (unchanged by the diff) still reads "then the request is retried once.


private _last401Response?: Response;
// True between a 401-triggered reconnect and the next successful open.
private _connectAuthRetried = false;

Copy link
Copy Markdown
Contributor Author

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 _connectAuthRetried field 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.json sets tabWidth: 4 and pnpm lint:all enforces 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 fail pnpm 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. the retried ? new SdkHttpError(...) branch at lines 264-274 and this._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.

Comment on lines +264 to 277
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()
);
this._last401Response = undefined;
reject(error);
this.onerror?.(error);
return;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 onclose, so Client requests hang until timeout. The terminal arm at packages/client/src/client/sse.ts:264-277 only calls reject (a no-op once start() resolved) and onerror; the EventSource is closed and nothing reconnects. Fix: when the terminal 401 arrives after the stream had opened (e.g. this._endpoint is set), also run void this.close() as the endpoint handler does at sse.ts:302, so onclose reaches Protocol and pending requests settle; the same applies to the UnauthorizedError arm. On base this population looped through onUnauthorized() instead of going silent.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: 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.

Comment on lines 285 to +287
this._eventSource.onopen = () => {
// The connection is open, but we need to wait for the endpoint to be received.
this._connectAuthRetried = false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 endpoint still get an unbounded refresh loop: start() never settles and onUnauthorized() runs on every cycle, the exact symptom this PR claims to fix. The guard is cleared in onopen at packages/client/src/client/sse.ts:287, which fires on the 200 response before the connect has succeeded. The eventsource auto-reconnect then gets 401 at sse.ts:244 with the flag already false and refreshes again, forever. Fix: bound the connect attempt as a whole, e.g. clear _connectAuthRetried only when start() resolves (the endpoint handler at sse.ts:306) or count refreshes per start() call, so a stream that never delivers endpoint cannot re-arm the retry.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: 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 endpoint event, then 401 on the automatic reconnect, and so on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSE stream retries a 401 forever when onUnauthorized keeps resolving

2 participants