Critical Token Refresh Bugs - Prevents Proactive Refresh
Initial Checks
- [x] I confirm that I'm using the latest version of MCP Python SDK
- [x] I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Description
tldr: Circular logic in mcp/client/auth.py creates two critical issues that prevent proactive token refresh; this would cause frequent re-auth for all users but it creates a third, worse issue for Clients that use TokenStorage (especially noticeable in a multi-user setup)
Background
In mcp/client/auth.py there are only 3 places where OAuthContext.token_expiry_time is set:
- During init it is set to None
- During full re-auth after receiving a 401 (in call to
_handle_token_response) - When a token is deemed invalid in
is_token_valid(at start ofasync_auth_flow)
Issue 1 - Token validity check treats None as Valid
The ultimate problem is that when token_expiry_time is None, a token will pass validity checks, per the below.
def is_token_valid(self) -> bool:
"""Check if current token is valid."""
return bool(
self.current_tokens
and self.current_tokens.access_token
and (not self.token_expiry_time or time.time() <= self.token_expiry_time)
# ^^^^^^^^^^^^^^^^^^ None makes it perpetually valid so never needs pro-active refresh
)
This is probably intended behavior to enable None to represent 'perpetual tokens'.
It sets up 2 fatal problems.
Issue 2 - Expired tokens get converted to 'perpetual tokens'
In update_token_expiry an expired token with a token.expires time of 0 is 'Falsey' which sets token_expiry_time to None.
def update_token_expiry(self, token: OAuthToken) -> None:
"""Update token expiry time."""
if token.expires_in: # <============================ 0 evaluates to False
self.token_expiry_time = time.time() + token.expires_in
else:
self.token_expiry_time = None # <============================ Makes it perpetually valid
Per Issue 1, when token_expiry_time is None the token is always valid (i.e. perpetual).
Undesirable Result - Only full re-auth can refresh a 'perpetual token' that goes stale
Because the token is now perpetual, it never gets proactively refreshed so it always requires a 401 and full re-auth to refresh.
Issue 3 - Clients with storage always start with token_expiry_time set to None
The OAuth init does not check token expiry:
async def _initialize(self) -> None:
"""Load stored tokens and client info."""
self.context.current_tokens = await self.context.storage.get_tokens()
self.context.client_info = await self.context.storage.get_client_info()
self._initialized = True
This is often fine for Clients that persist.
For newly or frequently instantiated Clients that use Token Storage, token_expiry_time is None by default.
Due to Issue 1 that None value cause the token to become perpetual.
Due to Issue 2 the perpetual token is never checked for refresh.
As a result, tokens from storage (which are probably stale) are never checked prior to their first call. So they always trigger a 401 and revert to full re-auth.
Proposed Fixes
Token update should check if token has an expires_in attribute and for an explicit None. This preserves perpetual tokens while treating expired tokens appropriately.
def update_token_expiry(self, token: OAuthToken) -> None:
"""Update token expiry time."""
if hasattr(token, 'expires_in') and token.expires_in is not None: # <================ Fix
self.token_expiry_time = time.time() + token.expires_in
else:
self.token_expiry_time = None
Also, the validity check should only work when self.token_expiry_time=None explicitly instead of when 'Falsey':
def is_token_valid(self) -> bool:
"""Check if current token is valid."""
return bool(
self.current_tokens
and self.current_tokens.access_token
and (self.token_expiry_time is None or time.time() <= self.token_expiry_time) # <============== Fix
#^^^^^^^^^^^^^^^^^^^^^^^ None can remain signal for perpetual token
)
Finally, OAuthContext._initialize should call update_token_expiry so stored tokens aren't treated as perpetual by default:
async _initialize(self):
"""Initialize and properly set token expiry from stored tokens."""
self.context.current_tokens = await self.context.storage.get_tokens()
self.context.client_info = await self.context.storage.get_client_info()
# Fix: Update token expiry if tokens loaded from storage
if self.context.current_tokens:
self.context.update_token_expiry(self.context.current_tokens)
self._initialized = True
Example Code
Python & MCP Python SDK
I'm on 1.12.4 but the code is still the same for 1.13.1
Another bug that ruins token refresh:
tldr: Any person using an MCP Client to connect to an MCP Proxy Server (e.g. that wraps Gmail APIs) can't refresh tokens because oauth_metadata is not set when reviving Client state from token_storage (so it constructs and calls the wrong /token endpoint).
The Setup - An MCP Client is connecting to an MCP Proxy Server (i.e. that wraps a 3rd party resource)
- For MCP Proxy Servers, your well-known endpoint is your url (e.g.
auth.palpable.io/.well-known...). - From that endpoint, you serve up
OauthMetadafor your 3rd party's endpoints (e.g.authorization_endpoint: "https://login.microsoft...) - This works on initial auth because
async_auth_flowchecks your endpoint and stores 3rd party endpoints inOauthMetadata
The Problem - When a Client is instantiated from Token Storage, OAuthMetadata is missing - so it tries to construct a /token url from server_url (will be wrong because it is a proxy server)
- If a client is killed and re-instantiated using tokens, no
OAuthMetadatais present (only filled in discovery) - The
_refresh_tokenmethod tries to get the /token endpoint from storedOAuthMetadata(missing) - As a fallback, it manually adds
/tokento theauth_base_urlwhich is derived from theserver_urlset on init -
Problem: For an MCP Proxy Server, the
auth_base_urlwill be wrong (e.g. auth.palpable.io/token instead of auth.microsoft...)
async def _refresh_token(self) -> httpx.Request:
"""Build token refresh request."""
if not self.context.current_tokens or not self.context.current_tokens.refresh_token:
raise OAuthTokenError("No refresh token available")
if not self.context.client_info:
raise OAuthTokenError("No client info available")
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: <=====Fails here... not oauth_metadata
token_url = str(self.context.oauth_metadata.token_endpoint)
else:
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
token_url = urljoin(auth_base_url, "/token") <========================Falls back to MCP Proxy url which fails
- Upon 404 from the/token endpoint all the tokens are cleared and auth is started from scratch
Potential Fixes -
- Force discovery on _initialize
async def _initialize(self) -> None:
self.context.current_tokens = await self.context.storage.get_tokens()
self.context.client_info = await self.context.storage.get_client_info()
# MISSING: self.context.update_token_expiry(self.context.current_tokens)
# ALSO MISSING: <discovery> + set OAuthMetadata
self._initialized = True
-
Add get and set
OAuthMetaDatatoTokenStorage. Or add toOAuthClientInformationFulland extract it from a call toget_client_info. -
Create a
proxy_server_urlattribute inOAuthContextand have the discovery checks check it on init but useserver_urlfor the fallback /token endpoint construction.
Should the values of token expired_at or created_at be persisted in the token storage layer? Can we make this explicit in the token storage protocol? Currently users need to guess this requirement to successfully implement a token storage with effective token refresh. Or maybe be part of the OAuthToken (but this introduces an unnecessary field for the server)?
Should the values of token expired_at or created_at be persisted in the token storage layer? Can we make this explicit in the token storage protocol? Currently users need to guess this requirement to successfully implement a token storage with effective token refresh. Or maybe be part of the OAuthToken (but this introduces an unnecessary field for the server)?
Currently, per logic above, it doesn't matter if it is in the token layer.
The context layer ensures refresh never happens.
Agreed, but this fix should be enough? Combined with update on expiry logic using expired_at.
async def _initialize(self):
"""Initialize and properly set token expiry from stored tokens."""
self.context.current_tokens = await self.context.storage.get_tokens()
self.context.client_info = await self.context.storage.get_client_info()
# Fix: Update token expiry if tokens loaded from storage
if self.context.current_tokens:
self.context.update_token_expiry(self.context.current_tokens)
self._initialized = True
Also +1 for storing OAuthMetadata.
This is sadly a crazy bug that is effecting every other major MCP - including Linear, substack etc.