feat(auth): persist oauth_metadata via TokenStorage to fix refresh after restart#2492
Open
mangibaud33 wants to merge 1 commit intomodelcontextprotocol:mainfrom
Open
Conversation
…ter restart Closes part of modelcontextprotocol#1318. Problem: OAuthClientProvider._initialize() restores tokens and client_info from storage but not oauth_metadata. After a restart with cached tokens, _refresh_token() falls back to <base_url>/token (via get_authorization_base_url + urljoin), which is incorrect for servers whose token endpoint is at a non-standard path (e.g. HubSpot's MCP Auth App uses /oauth/v3/token). Refresh requests return 404, cascading into a full interactive OAuth flow that cannot complete in non-interactive environments (daemons, containers, long-running services). Fix: add optional get_oauth_metadata / set_oauth_metadata methods to the TokenStorage protocol. _initialize now restores metadata alongside tokens and client_info via a getattr fallback that preserves backward compatibility with storage implementations predating this API; they return None and the refresh path falls back to the legacy behaviour as before. Discovered metadata is persisted after OASM discovery in the 401 flow so subsequent restarts can resolve the correct token endpoint without rediscovery. Coverage: added 5 tests (3 feature + 2 to cover the getattr fallback branches and the Protocol default bodies). Also removed three "# pragma: no cover" markers on lines now exercised by the new tests per AGENTS.md.
This was referenced Apr 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
OAuthClientProvider._initialize()restorescurrent_tokensandclient_infofrom storage but notoauth_metadata. After a restart with cached tokens,_refresh_token()loses the authoritative token endpoint discovered during the prior session and falls back to<base_url>/token— wrong for servers whose token endpoint sits on a non-standard path.This PR makes metadata persistence symmetric with tokens/client_info: optional
get_oauth_metadata/set_oauth_metadatamethods onTokenStorage, loaded in_initialize, saved after OASM discovery.Closes part of #1318 (Bug 2 — the
_refresh_tokenfallback URL issue). Bug 1 from #1318 (token_expiry_timenot restored) is tracked separately in #1784.Repro
Concrete case: HubSpot MCP Auth App (
mcp.hubspot.com). Its discovery document reports"token_endpoint": "https://mcp.hubspot.com/oauth/v3/token". After completing the initial OAuth flow and storing tokens to disk, a restart causes the next refresh attempt to hithttps://mcp.hubspot.com/token(the<base_url>/tokenfallback), which returns 404 HTML. Thehttpx.Response.json()validation then fails,_handle_refresh_responsereturns False, and the SDK cascades into a full interactive OAuth flow. That flow cannot complete in non-interactive environments (daemons, containers, CI).Fix approach
Alternative considered: fetch discovery inline in
_initializevia a privatehttpx.AsyncClient. Rejected — inconsistent with theyield-based flow, complicates testing (would need to mock HTTP in_initializetests), and can't easily use the same TLS / proxy configuration the host httpx client is using.Chosen: persist
oauth_metadataalongside tokens/client_info. Symmetric with existing storage pattern, zero extra HTTP call on hot path, cache survives restarts.Changes
src/mcp/client/auth/oauth2.pyTokenStorageProtocol: add two methods with default no-op bodies (return None/return). Marked as optional in docstrings._initialize: loadoauth_metadataviagetattr(storage, 'get_oauth_metadata', None)— preserves backward compatibility with duck-typed storage implementations predating this API (they returnNoneand the refresh path falls back to the legacy behaviour).async_auth_flow401 branch: after successful OASM discovery, persist viagetattr(storage, 'set_oauth_metadata', None).# pragma: no covermarkers on lines now exercised by the new tests (_initialize,async_auth_flow._initializecall,async_auth_flow except Exception), per AGENTS.md.tests/client/test_auth.py5 new tests:
test_initialize_restores_oauth_metadata_initializereads metadata from storage into contexttest_refresh_token_uses_persisted_metadata_endpoint_refresh_tokenuses the correct non-standard endpointtest_initialize_backward_compat_without_metadata_methodsget_oauth_metadata) doesn't raiseAttributeErroron inittest_token_storage_protocol_default_metadata_methodsNone/ no-op for subclasses choosing not to overridetest_auth_flow_discovery_with_legacy_storage_skips_metadata_persistencegetattrfallback skips persistence silentlyMockTokenStoragealso updated to implement the two new methods.Backward compatibility
MockTokenStoragepattern): continue to work thanks to thegetattrfallback in the provider. They will not persist metadata and will re-trigger discovery on each restart, identical to pre-PR behaviour.Zero breaking change for any existing
TokenStorageimplementation.Test plan
uv run --frozen pytest tests/client/test_auth.py— 101 passeduv run --frozen pyright src/mcp/client/auth/oauth2.py tests/client/test_auth.py— 0 errorsuv run --frozen ruff format + check— cleancoverage report --include='src/mcp/client/auth/oauth2.py'— 99.38% (parity withmainbaseline; the only missing line is the pre-existingreturn data, headersinprepare_token_auth, covered by other test files in CI)UV_FROZEN=1 uv run --frozen strict-no-cover— ✅ (removed 3 obsolete pragmas)Notes for reviewers
v1.xon request (small diff, no API break).token_expiry_timebug: Fixtoken_expiry_timeupon context initialization for stored tokens. #1784. I've commented there offering to help finalize if @keurcien has bandwidth; otherwise I can follow up with a separate rebased PR once this one lands.