Repository navigation
Mid-session re-auth after refresh-token expiry dead-ends: send() gets "REDIRECT" and throws UnauthorizedError without ever exchanging the new code #2510
Description
Activity
I'd like to work on this — the mid-session re-auth dead-end when
auth()returnsREDIRECTafter a refresh-token expiry. I can mirror the existingwaitForAuthCode()/finishAuth()completion path used for the analogous case in #2286. Happy to be assigned.Confirming the root cause on current
mainand asking for a direction preference before anyone puts up a PR, since the fix shape here is a design call rather than a mechanical one.Both the POST path (
_send) and the GET/SSE path (_startOrAuthSse) inpackages/client/src/client/streamableHttp.tstreat a non-AUTHORIZEDresult fromauth()as a hard failure:// mid-session 401, refresh token also expired -> auth() runs a fresh // authorization-code flow (opens the browser) and returns 'REDIRECT' throw new UnauthorizedError();
Worth noting that the #2286 step-up mechanism doesn't already cover this:
_stepUpAuthorizerecovers only whenauth()can reachAUTHORIZEDnon-interactively (scope-union + superset-gated refresh bypass). When it returns'REDIRECT', that path also throws (theif (result !== 'AUTHORIZED') throw new UnauthorizedError()guard). So there is genuinely no mid-session path that completes an interactive redirect today — only the initial-connect path wireswaitForAuthCode()->finishAuth(). A redirect that starts mid-session orphans its code.Two shapes I can see, and I'd rather build the one you'd accept:
-
Transport-internal completion — on a mid-session
'REDIRECT', block on the callback result and callfinishAuth()before retrying the in-flight request, mirroring the initial-connect flow. Keeps the "user just re-auths in place" UX that ChatGPT's client already gives, but moves callback-waiting responsibility into the transport (it currently lives in the host, e.g. mcp-remote's local callback server). -
Keep the host-driven
finishAuth()contract, but stop dead-ending — surface the mid-session'REDIRECT'as a typed, recoverable signal (distinct from the terminalUnauthorizedError) so a host can complete it viafinishAuth()and resume, and document that hosts must wire redirect completion mid-session too. Smaller, more conservative, but pushes the fix partly onto callers.
Preference between (1) and (2) — or a third shape you have in mind? Happy to write it up with a regression test (refresh ->
400 invalid_grant-> fresh code exchanged -> in-flight request retried and succeeds). Not trying to jump ahead of @zerafachris on the implementation; just want the direction pinned down first.-
- added a commit that references this issue
on Jul 24, 2026 - addedauthIssues and PRs related to Authentication / OAuthIssues and PRs related to Authentication / OAuthbugSomething isn't workingSomething isn't workingneeds designValid issue but needs maintainer alignment on design or approachValid issue but needs maintainer alignment on design or approach
on Aug 17, 2026 - addedv2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixesIdeas, requests and plans for v2 of the SDK which will incorporate major changes and fixesv1Issues / PRs related to v1.xIssues / PRs related to v1.x
on Aug 18, 2026
Summary
When an access token and its refresh token have both expired during an active session,
StreamableHTTPClientTransportstarts a fresh authorization-code flow. The browser opens, the user logs in, and the authorization server redirects back with a new code. The problem is that the transport never exchanges that code for tokens.auth() returns "REDIRECT", and send() treats anything other than "AUTHORIZED" as a failure, so it throws
UnauthorizedErrorand the connection dies. The user sees a generic error instead of being re-authenticated.The interactive redirect is only ever completed by the initial connect path (waitForAuthCode() then finishAuth()). There is no equivalent handler for a redirect that starts mid-session, so the code that lands on the callback server is never picked up.
Environment
@modelcontextprotocol/sdk: 1.25.3 (the relevant code path is unchanged on main for this trigger)StreamableHTTPClientTransport(Streamable HTTP)mcp-remote0.1.38 (which bundles SDK 1.25.3) and through the native Claude connector. Both are built on this SDK and fail in the same way.WWW-Authenticatechallenge,token_type: "Bearer", and rotating refresh tokens are all spec-compliant.Counter-example: the same server connected to ChatGPT re-authenticates cleanly. ChatGPT's client completes the interactive code exchange mid-session, which this SDK does not.
Steps to reproduce
Server-side log, note the missing final /token call:
Expected behavior
After the browser round-trip produces a fresh authorization code, the transport should exchange it (via finishAuth() / grant_type=authorization_code), obtain new tokens, and retry the in-flight request, the same way the initial connect path does. The user should end up re-authenticated without a hard error.
Actual behavior
auth() returns "REDIRECT", send() throws UnauthorizedError, and the transport closes. The redirect that was started is orphaned because the code is never consumed, so the session stays broken until the whole client or connector is restarted.
Root cause
In StreamableHTTPClientTransport.send(), on a mid-session 401:
Inside auth():
"REDIRECT" propagates back to send(), which only accepts "AUTHORIZED" and throws. The authorization code that arrives on the callback server after the browser round-trip is never picked up, because only the initial connect path wires up waitForAuthCode() then finishAuth(). There is no mid-session mechanism to wait for the redirect result and finish the exchange.
Impact
Any OAuth-protected Streamable HTTP MCP server hits this whenever a refresh token expires during an active session, which happens with idle sessions, short or rotating refresh-token lifetimes, and revoked grants. Because both mcp-remote and native SDK-based connectors share this code, users get a generic failure and have to fully reconnect or restart instead of being re-prompted in place.
Possible fix
Give send() (and the streaming reconnect path) a way to wait for and complete a mid-session redirect, mirroring the initial-connect handling. In practice that means blocking on the callback result and calling finishAuth() with the returned code before retrying, instead of throwing on "REDIRECT". The redirect-completion work introduced in #2286 (for the 403 insufficient_scope step-up case in #2255) looks like the right general mechanism. This issue asks for the refresh-token-expiry trigger to be handled the same way.
Related
_hasCompletedAuthFlow#1317 - full re-auth blocked after refresh failure (closest existing match)