Repository navigation
Description
Initial Checks
- I confirm that I'm using the latest version of MCP Python SDK
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Description
_handle_refresh_response() in src/mcp/client/auth/oauth2.py replaces the stored token object with the parsed refresh response as-is. When the authorization server does not return a new refresh_token in the refresh response, the previously stored refresh_token is lost. After the first successful refresh, can_refresh_token() returns False and all subsequent refreshes fail, forcing full re-authentication.
Per RFC 6749 Section 6, issuing a new refresh token in the refresh response is optional:
"The authorization server MAY issue a new refresh token, in which case the client MUST discard the old refresh token and replace it with the new refresh token."
The implicit corollary is that if the server does not issue a new refresh token, the client must preserve the existing one.
Many OAuth providers omit the refresh_token from refresh responses by default (e.g., Google, Auth0 without rotation enabled, Okta in persistent token mode). This makes the current behavior a practical issue for a wide range of OAuth servers.
Current Code
async def _handle_refresh_response(self, response: httpx.Response) -> bool:
...
token_response = OAuthToken.model_validate_json(content)
self.context.current_tokens = token_response # overwrites old refresh_token
self.context.update_token_expiry(token_response)
await self.context.storage.set_tokens(token_response)
...Proposed Fix
Preserve the existing refresh_token when the refresh response omits one:
token_response = OAuthToken.model_validate_json(content)
# Per RFC 6749 Section 6, the server MAY issue a new refresh token.
# If the response omits it, preserve the existing one.
if not token_response.refresh_token and self.context.current_tokens and self.context.current_tokens.refresh_token:
token_response = token_response.model_copy(
update={"refresh_token": self.context.current_tokens.refresh_token}
)
self.context.current_tokens = token_response
...Related Issues
- Critical Token Refresh Bugs - Prevents Proactive Refresh #1318 - Critical Token Refresh Bugs (token expiry tracking, P1)
- Framework does not handle refreshed tokens correctly #1250 - Framework does not handle refreshed tokens correctly (P1)
- PR SEP-2207: Refresh token guidance #2039 - SEP-2207: Refresh token guidance (adds
offline_accessscope, does not address preservation)
Python & MCP Python SDK
v1.26.0
Activity
fix(oauth): preserve existing refresh_token when refresh response omi…
fix(oauth): preserve existing refresh_token when refresh response omi…
fix(oauth): preserve existing refresh_token when server omits it
fix: preserve existing refresh_token when server omits it in refresh …
fix(auth): preserve existing refresh_token when server omits it
Fix: preserve existing refresh_token when server omits it in refresh …
ekcheungAI commentedon Mar 14, 2026
The refresh path here looks like a straight RFC 6749 §6 mismatch rather than an OAuth-provider quirk.
If
_handle_refresh_response()replacescurrent_tokenswith the parsed refresh response as-is, then any provider that omitsrefresh_tokenon refresh will silently downgrade the session into a non-refreshable one after the first rotation. That matches the failure mode in the report: first refresh succeeds,can_refresh_token()flips false, later refreshes fail.The minimal fix surface seems to be local to token replacement:
refresh_tokenis absent, preserve the previously stored one,That also lines up with the RFC wording: if a new refresh token is issued, replace the old one; otherwise keep using the existing token. A good regression test would be a provider response that updates
access_token/expiry but omitsrefresh_token, then verifies a second refresh still works without re-auth.maxisbey commentedon Aug 14, 2026
This was fixed on
mainin #2946:_handle_refresh_responsenow carries the existingrefresh_tokenforward when the refresh response omits it (the RFC 6749 §6 case you described), and that shipped in v2.0. The 1.x line still has the old wholesale overwrite, but 1.x is only taking critical fixes at this point, so I'm closing this as fixed in v2. Feel free to reopen if you're stuck on 1.x and this is blocking you there.AI Disclaimer