Repository navigation
OAuthClientProvider._initialize() breaks transparent refresh: missing update_token_expiry + missing oauth_metadata load #3250
Description
Activity
- addedv2Affects the v2 line (2.x on main)Affects the v2 line (2.x on main)P3Nice to haves, rare edge casesNice to haves, rare edge casesP1Significant bug affecting many users, highly requested featureSignificant bug affecting many users, highly requested featureand removedP3Nice to haves, rare edge casesNice to haves, rare edge cases
on Aug 11, 2026 - added a commit that references this issue
on Aug 18, 2026 Confirming both bugs from production, with an offline reproduction, in case it helps prioritise.
Impact we hit (2026-10-11): a non-interactive hourly job (systemd timer) talking to a remote MCP server whose access tokens live 7 days (
expires_in: 604800) with a refresh token.OAuthClientProviderran 168 jobs on one access token, never refreshed (set_tokenswas never called;token_expiry_timestayedNoneafter_initialize), and when the server finally answered 401 the SDK went straight to_perform_authorization()→redirect_handler, which a headless job cannot complete. Bug 2 would have bitten next: the server's token endpoint is on a different host than the MCP server (discovered via the protected-resource metadata), so theurljoin(base, "/token")fallback is a 404.Versions checked (identical
_initialize/is_token_valid/_refresh_token): 1.26.0, 1.28.1, 1.30.0.Minimal reproduction (no network): storage returns a token saved long ago; the MCP server mock answers 401 to any bearer and serves PRM + AS metadata; the redirect handler raises. Expected: a
POST …/oauth2/tokenwithgrant_type=refresh_token. Actual: no token-endpoint request at all, the stale bearer is sent, then the browser flow is demanded.import asyncio, httpx from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken from pydantic import AnyUrl SERVER, AS = "https://mcp.example.com/mcp", "https://auth.example.com" class Store(TokenStorage): async def get_tokens(self): return OAuthToken(access_token="AT-OLD", expires_in=604800, refresh_token="RT-OLD") async def set_tokens(self, t): raise AssertionError("never called") async def get_client_info(self): return OAuthClientInformationFull(client_id="cid", redirect_uris=[AnyUrl("http://localhost:8765/callback")], grant_types=["authorization_code", "refresh_token"], response_types=["code"], token_endpoint_auth_method="none") async def set_client_info(self, i): pass log = [] def handler(req: httpx.Request) -> httpx.Response: log.append((req.method, str(req.url), req.content.decode())) u = str(req.url) if u.startswith(SERVER): return httpx.Response(401, headers={"WWW-Authenticate": f'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"'}) if "oauth-protected-resource" in u: return httpx.Response(200, json={"resource": SERVER, "authorization_servers": [AS]}) if "oauth-authorization-server" in u: return httpx.Response(200, json={"issuer": AS, "authorization_endpoint": f"{AS}/oauth2/authorize", "token_endpoint": f"{AS}/oauth2/token", "grant_types_supported": ["authorization_code", "refresh_token"], "response_types_supported": ["code"], "code_challenge_methods_supported": ["S256"]}) if u.endswith("/oauth2/token"): return httpx.Response(200, json={"access_token": "AT-NEW", "token_type": "Bearer", "expires_in": 604800, "refresh_token": "RT-NEW"}) return httpx.Response(404) class Browser(RuntimeError): pass async def redirect(url): raise Browser("interactive login demanded") async def callback(): raise AssertionError async def main(): prov = OAuthClientProvider(server_url=SERVER, client_metadata=OAuthClientMetadata( client_name="x", redirect_uris=[AnyUrl("http://localhost:8765/callback")], grant_types=["authorization_code", "refresh_token"], response_types=["code"], token_endpoint_auth_method="none"), storage=Store(), redirect_handler=redirect, callback_handler=callback) async with httpx.AsyncClient(auth=prov, transport=httpx.MockTransport(handler)) as c: try: await c.post(SERVER, json={}, headers={"mcp-protocol-version": "2025-06-18"}) except Browser as e: print("RESULT:", e) print("token_expiry_time after load:", prov.context.token_expiry_time) print("refresh grant POSTed:", any("grant_type=refresh_token" in b for *_, b in log)) for m, u, _ in log: print(" ", m, u) asyncio.run(main())
Output on 1.30.0:
RESULT: interactive login demanded token_expiry_time after load: None refresh grant POSTed: False POST https://mcp.example.com/mcp GET https://mcp.example.com/.well-known/oauth-protected-resource/mcp GET https://auth.example.com/.well-known/oauth-authorization-serverNote the storage already carries
expires_in; without an issued-at it cannot be turned into an absolute expiry on load, so a fix probably needsTokenStorageto persist (orOAuthTokento carry) anexpires_at, as the issue suggests. Our workaround is to run the refresh grant ourselves before constructing the provider.
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.