Skip to content

SSE stream retries a 401 forever when onUnauthorized keeps resolving #2894

Description

@feiiiiii5

What happens

SSEClientTransport retries the SSE connection after onUnauthorized() resolves, and the retry has no cap. If the server keeps answering the EventSource GET with 401 and the refresh keeps resolving, onUnauthorized() is called forever, the EventSource is rebuilt forever, and connect() never settles. The simple bearer provider the JSDoc recommends — { token, onUnauthorized } — is exactly that provider.

Measured on main (dd22ba25), local server always 401, provider that always resolves:

EventSource GET attempts (1.5 s) : 25
onUnauthorized() invocations     : 25
start() settled?                 : never

The 25 is a backstop I had to add to end the test. Without it the event loop is starved by the retry loop and the run times out.

The same transport's POST path does not do this:

POST attempts                : 2
onUnauthorized() invocations : 1
send() rejected with         : SdkHttpError: Server returned 401 after re-authentication

The conflict

Three parts of the repo disagree about what the second 401 should do.

The documented contract — packages/client/src/client/auth.ts:88-91 and the same text on the transport's authProvider option in packages/client/src/client/sse.ts:73-77:

Called when the server responds with 401. If provided, the transport will await this, then retry the request once. If the retry also gets 401, or if this method is not provided, the transport throws UnauthorizedError.

The other two implementations follow it: sse.ts:408 guards with !isAuthRetry and throws SdkHttpError(ClientHttpAuthentication, ...) at :425; streamableHttp.ts:538,582,599-606 has the same shape. packages/client/test/client/tokenProvider.test.ts:66,89,177 pins "retry once, then throw" for those.

Two tests in sse.test.ts pin per-attempt refresh for the SSE stream instead. :1815 records that an earlier version did pass isAuthRetry into _startOrAuth(), and that a later 401 on the retried connection then threw instead of refreshing, so the retry was changed to call _startOrAuth() fresh — that fixed token expiry on reconnect and, as a side effect, removed the cap. :1854 expects onUnauthorized twice against a server that always 401s. Neither says "retry forever is the intent"; both are regression tests for specific past bugs. But together they mean changing the documented behaviour would have to change both, which is a maintainer call rather than a bug fix.

Two ways out

A. Enforce the documented contract on the SSE stream. Thread the flag into _startOrAuth() the way streamableHttp.ts does, but reset it as soon as the retried connection opens, so a later 401 on a healthy connection still refreshes — the case the :1815 regression test protects. The :1854 test would need its expectation updated from two onUnauthorized calls to one, and the second failure becomes the SdkHttpError rather than refresh failed. This makes the three transports agree and removes the loop.

B. Keep refreshing per attempt, but bound it. Add a cap (attempts or a deadline) so a provider that can never satisfy the server fails diagnosably instead of looping. The repo already has this shape in the streamable HTTP path (sse.test.ts:1763 enforces circuit breaker on double-401), so B is the smaller behavioural change and keeps both existing tests as they are.

I lean towards A, because the interface contract, the two sibling implementations and their tests all say "once", and B still leaves a caller waiting on a connection that can never succeed. But either way the maintainers should pick rather than have a third interpretation land in a PR. I will write it for whichever is chosen, with the reproduction above turned into a test in packages/client/test/client/sse.test.ts.

Environment

@modelcontextprotocol/sdk on main (dd22ba25), Node 22, macOS, local HTTP server only.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions