Skip to content

Mid-session re-auth after refresh-token expiry dead-ends: send() gets "REDIRECT" and throws UnauthorizedError without ever exchanging the new code #2510

Description

@evdbogaard

Summary

When an access token and its refresh token have both expired during an active session, StreamableHTTPClientTransport starts 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 UnauthorizedError and 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)
  • Transport: StreamableHTTPClientTransport (Streamable HTTP)
  • Reproduced through mcp-remote 0.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.
  • Authorization server: an OAuth 2.1 + PKCE proxy in front of Auth0. Discovery, the WWW-Authenticate challenge, token_type: "Bearer", and rotating refresh tokens are all spec-compliant.
  • Node: v24

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

  1. Connect a client built on this SDK to a Streamable HTTP MCP server that uses OAuth with short access-token and refresh-token lifetimes.
  2. Let the access token expire. The transport calls /token with grant_type=refresh_token and it succeeds. This path works correctly.
  3. Let the refresh token also expire, then trigger any tool call.
  4. The transport calls /token with grant_type=refresh_token. The server returns 400 invalid_grant.
  5. The SDK invalidates its tokens and starts a fresh authorization-code flow. The browser opens, the user authenticates, and the authorization server redirects to the client callback with a valid new code.
  6. The new code is never exchanged. No follow-up /token request with grant_type=authorization_code is ever made.

Server-side log, note the missing final /token call:

# access-token refresh (works)
OAuth /token: grantType=refresh_token

# refresh-token expired, full re-auth begins
OAuth /token: grantType=refresh_token          -> 400 invalid_grant
OAuth /authorize: redirectUri=.../callback state=... hasCodeChallenge=True
OAuth /callback: hasCode=True error=''
OAuth /callback: redirecting to MCP client .../callback?code=...&state=...
# expected here: OAuth /token grantType=authorization_code   (never happens)

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:

const result = await auth(this._authProvider, { serverUrl, ... });
if (result !== "AUTHORIZED") {
  throw new UnauthorizedError();   // "REDIRECT" lands here
}

Inside auth():

  • The 400 invalid_grant from the refresh attempt is caught, which triggers invalidateCredentials("tokens") and a re-run of authInternal().
  • With no usable refresh token left, authInternal() runs startAuthorization() then redirectToAuthorization() (which opens the browser) and returns "REDIRECT".

"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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions