Two bugs in OAuthClientProvider._initialize() combine to break transparent token refresh
Summary
When a client process restarts (or any time OAuthClientProvider is reconstructed), the SDK fails to transparently refresh expired access_tokens even when a valid refresh_token is on disk and the IdP would happily exchange it. Users are forced through an interactive OAuth re-auth on every process restart — even when the refresh_token is still valid for up to 15 days per the IdP's policy.
This affects every MCP server that issues short-lived access_tokens (~15 min) with longer-lived refresh_tokens — Fold MCP, Notion, GitHub PAT-rotated OAuth, any Hydra-style server, etc. — i.e. the entire modern OAuth ecosystem. The symptom is indistinguishable from the server revoking the refresh_token.
Bug 1: _initialize() doesn't compute token_expiry_time
_initialize() loads current_tokens from storage but never calls context.update_token_expiry(token). So context.token_expiry_time stays None.
Then is_token_valid():
defis_token_valid(self) ->bool:
returnbool(
self.current_tokensandself.current_tokens.access_tokenand (notself.token_expiry_timeortime.time() <=self.token_expiry_time)
)
When token_expiry_time is None, the second clause is not None or … = True, so the function unconditionally returns True regardless of whether the access_token is expired by 1 second or 1 hour.
The refresh-on-expiry guard in async_auth_flow:
ifnotself.context.is_token_valid() andself.context.can_refresh_token():
refresh_request=awaitself._refresh_token()
…
…never fires. Expired access_tokens are sent on every request, the server returns 401, and the user lands in the full-re-auth branch (async_auth_flow lines 514+) which forces interactive login.
This same fix is already applied on the write path: set_tokens() (the function called after a successful refresh) does call update_token_expiry(), and there's even a comment in set_tokens referencing "Fix A … OAuthTokens.expiresAt persistence" that describes the pattern. The read path (_initialize) just doesn't do the same thing.
Bug 2: _refresh_token() builds the wrong endpoint URL when oauth_metadata isn't loaded
_refresh_token() picks the token endpoint like this:
ifself.context.oauth_metadataandself.context.oauth_metadata.token_endpoint:
token_url=str(self.context.oauth_metadata.token_endpoint) # pragma: no coverelse:
auth_base_url=self.context.get_authorization_base_url(self.context.server_url)
token_url=urljoin(auth_base_url, "/token")
For a server like https://mcp.fold.money/mcp, the fallback path produces https://mcp.fold.money/token — 404. The correct endpoint for Hydra-style servers is https://mcp.fold.money/oauth/token.
oauth_metadata is normally populated via server discovery during the 401-handling flow (after a 401). But the refresh-on-expiry path runs before any 401 — it proactively refreshes when the token is expired, with no 401 yet. So oauth_metadata is never populated, and refresh fails silently with 404. The user then sees the 401 → re-auth loop as if the refresh_token itself were invalid.
The fix is for _initialize() to also load oauth_metadata from storage (e.g., via storage.load_oauth_metadata(), which the reference HermesTokenStorage implementation already provides).
Reproduction
Any MCP client using OAuthClientProvider against an IdP with ~15 min access_tokens and a Hydra-style token endpoint.
importasyncio, httpxfrommcp.client.auth.oauth2importOAuthClientProviderfrommcp.shared.authimportOAuthClientMetadata, OAuthToken, OAuthClientInformationFull# Suppose these came from persistent storage (Hermes's HermesTokenStorage# or any conforming storage impl):client_info=OAuthClientInformationFull.model_validate(client_info_dict)
current_tokens=OAuthToken.model_validate({
"access_token": "...", "token_type": "Bearer", "expires_in": 900,
"refresh_token": "...", "scope": "mcp:read",
})
class_Storage:
asyncdefget_tokens(self): returncurrent_tokensasyncdefset_tokens(self, t): current_tokens=t# in-memory for reproasyncdefget_client_info(self): returnclient_infoprovider=OAuthClientProvider(
server_url="https://mcp.fold.money/mcp",
client_metadata=OAuthClientMetadata(...),
storage=_Storage(),
)
awaitprovider._initialize()
print(provider.context.is_token_valid()) # → True, even if access_token is expiredprint(provider.context.token_expiry_time) # → None (Bug 1)# Now suppose we manually trigger the refresh (mimicking async_auth_flow):importtime# Force expires_in = 0 in the loaded token (the way HermesTokenStorage does it):# …# Refresh URL points to /token, not /oauth/token (Bug 2):req=awaitprovider._refresh_token()
print(req.url) # → "https://mcp.fold.money/token" (404), not "/oauth/token"Fix
In src/mcp/client/auth/oauth2.py, modify OAuthClientProvider._initialize():
asyncdef_initialize(self) ->None:
"""Load stored tokens and client info."""importasyncioas_asyncioself.context.current_tokens=awaitself.context.storage.get_tokens()
self.context.client_info=awaitself.context.storage.get_client_info()
# Fix bug 1: compute absolute expiry from the loaded token's# `expires_in`, mirroring what set_tokens() does on the write path.ifself.context.current_tokensisnotNone:
self.context.update_token_expiry(self.context.current_tokens)
# Fix bug 2: load oauth_metadata if the storage supports it, so# _refresh_token() can find the correct token_endpoint without# having to wait for server discovery.loader=getattr(self.context.storage, "load_oauth_metadata", None)
ifcallable(loader):
try:
meta=loader()
if_asyncio.iscoroutine(meta):
meta=awaitmetaifmetaisnotNone:
self.context.oauth_metadata=meta# type: ignore[assignment]exceptException:
passself._initialized=True
The reference storage (HermesTokenStorage in some downstream clients like hermes-agent) already implements load_oauth_metadata() returning OAuthMetadata.model_validate(<contents of {server}.meta.json>). For SDK-provided storage classes that don't yet implement this, the getattr guard makes the second fix a no-op — bug 2 only manifests for downstream storage classes that already populate .meta.json.
Live verification
Patched locally against mcp==1.28.1 on macOS (Hermes agent 0.20.0). 16-minute live repro against https://mcp.fold.money:
- Login via OAuth → fresh token, mtime T0.
- Wait 15 min past access_token expiry → token on disk is stale, mtime still T0 (SDK never touched file).
- Make MCP call with forced-expired access_token + fresh refresh_token.
- Without the fix: SDK sends expired token, gets 401, falls through to re-auth (browser prompt).
- With the fix: SDK calls
https://mcp.fold.money/oauth/token with grant_type=refresh_token, gets HTTP 200 with fresh rotated pair, writes back to disk. MCP call returns real data (verified: get_total_balance → ₹146,656.35 across 4 accounts).
AI disclosure
Drafted with AI assistance (GPT-class model). The bug analysis, code path tracing, fix design, and live verification were all done by a human reviewer who understood every line. The fix itself is 12 lines, two of which mirror the existing set_tokens write-path logic.
Two bugs in
OAuthClientProvider._initialize()combine to break transparent token refreshSummary
When a client process restarts (or any time
OAuthClientProvideris reconstructed), the SDK fails to transparently refresh expired access_tokens even when a validrefresh_tokenis on disk and the IdP would happily exchange it. Users are forced through an interactive OAuth re-auth on every process restart — even when the refresh_token is still valid for up to 15 days per the IdP's policy.This affects every MCP server that issues short-lived access_tokens (~15 min) with longer-lived refresh_tokens — Fold MCP, Notion, GitHub PAT-rotated OAuth, any Hydra-style server, etc. — i.e. the entire modern OAuth ecosystem. The symptom is indistinguishable from the server revoking the refresh_token.
Bug 1:
_initialize()doesn't computetoken_expiry_time_initialize()loadscurrent_tokensfrom storage but never callscontext.update_token_expiry(token). Socontext.token_expiry_timestaysNone.Then
is_token_valid():When
token_expiry_time is None, the second clause isnot None or …=True, so the function unconditionally returns True regardless of whether the access_token is expired by 1 second or 1 hour.The refresh-on-expiry guard in
async_auth_flow:…never fires. Expired access_tokens are sent on every request, the server returns 401, and the user lands in the full-re-auth branch (
async_auth_flowlines 514+) which forces interactive login.This same fix is already applied on the write path:
set_tokens()(the function called after a successful refresh) does callupdate_token_expiry(), and there's even a comment inset_tokensreferencing "Fix A … OAuthTokens.expiresAt persistence" that describes the pattern. The read path (_initialize) just doesn't do the same thing.Bug 2:
_refresh_token()builds the wrong endpoint URL whenoauth_metadataisn't loaded_refresh_token()picks the token endpoint like this:For a server like
https://mcp.fold.money/mcp, the fallback path produceshttps://mcp.fold.money/token— 404. The correct endpoint for Hydra-style servers ishttps://mcp.fold.money/oauth/token.oauth_metadatais normally populated via server discovery during the 401-handling flow (after a 401). But the refresh-on-expiry path runs before any 401 — it proactively refreshes when the token is expired, with no 401 yet. Sooauth_metadatais never populated, and refresh fails silently with 404. The user then sees the 401 → re-auth loop as if the refresh_token itself were invalid.The fix is for
_initialize()to also loadoauth_metadatafrom storage (e.g., viastorage.load_oauth_metadata(), which the referenceHermesTokenStorageimplementation already provides).Reproduction
Any MCP client using
OAuthClientProvideragainst an IdP with ~15 min access_tokens and a Hydra-style token endpoint.Fix
In
src/mcp/client/auth/oauth2.py, modifyOAuthClientProvider._initialize():The reference storage (
HermesTokenStoragein some downstream clients likehermes-agent) already implementsload_oauth_metadata()returningOAuthMetadata.model_validate(<contents of {server}.meta.json>). For SDK-provided storage classes that don't yet implement this, thegetattrguard makes the second fix a no-op — bug 2 only manifests for downstream storage classes that already populate.meta.json.Live verification
Patched locally against
mcp==1.28.1on macOS (Hermes agent 0.20.0). 16-minute live repro againsthttps://mcp.fold.money:https://mcp.fold.money/oauth/tokenwithgrant_type=refresh_token, gets HTTP 200 with fresh rotated pair, writes back to disk. MCP call returns real data (verified:get_total_balance→ ₹146,656.35 across 4 accounts).AI disclosure
Drafted with AI assistance (GPT-class model). The bug analysis, code path tracing, fix design, and live verification were all done by a human reviewer who understood every line. The fix itself is 12 lines, two of which mirror the existing
set_tokenswrite-path logic.