modelcontextprotocol / modelcontextprotocol/typescript-sdk
Mid-session re-auth after refresh-token expiry dead-ends: send() gets "REDIRECT" and throws UnauthorizedError without ever exchanging the new code
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
Description
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-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. - Authorization server: an OAuth 2.1 + PKCE proxy in front of Auth0. Discovery, the
WWW-Authenticatechallenge,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
- Connect a client built on this SDK to a Streamable HTTP MCP server that uses OAuth with short access-token and refresh-token lifetimes.
- Let the access token expire. The transport calls /token with grant_type=refresh_token and it succeeds. This path works correctly.
- Let the refresh token also expire, then trigger any tool call.
- The transport calls /token with grant_type=refresh_token. The server returns 400 invalid_grant.
- 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.
- 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
- #1317 - full re-auth blocked after refresh failure (closest existing match)
- #2255 and #2286 - 403 insufficient_scope step-up re-auth (same "throw instead of complete redirect" mechanism, different trigger; fix merged to main)
- #1641 - transport cannot restart after close()
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in StreamableHTTPClientTransport.send() and trace the auth() path for a refresh-token invalid_grant, then compare it with the initial connect flow using waitForAuthCode() and finishAuth(). Review the redirect-completion mechanism from #2286. Done means a mid-session REDIRECT waits for the callback, exchanges the code with finishAuth(), and retries the in-flight request instead of throwing UnauthorizedError.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, authentication
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100