Both ClientOAuthProvider (_tokenCache, _authServerMetadata) and the new CrossApplicationAccessProvider (_cachedTokens, _resolvedIdpTokenEndpoint, added in #1305) lazily populate mutable instance fields during 401 handling without synchronization.
Concurrent challenges are plausible whenever an HTTP-based transport (StreamableHttpClientTransport, SseClientTransport) has multiple in-flight requests when a token expires — each HttpClient.SendAsync is independent, nothing in the transport or McpSession layers serializes them, and each one will independently see 401 and invoke the auth flow. Consequences:
- redundant token exchanges (extra IdP round-trips, possible rate-limit hits), and
- races on the field writes (torn reads of the cached
TokenContainer, lost metadata).
Prior art in other SDKs
-
TypeScript (CrossAppAccessProvider._tokens) — plain field with getter/setter, no locking, no documented concurrency contract. Same state we''re in.
-
Go (EnterpriseHandler.tokenSource) — written from Authorize with no mutex, no documented concurrency contract. Same state we''re in.
-
Python (PR #1721) — coalesces refreshes through the parent OAuthClientProvider''s OAuthContext.lock, taken inside async_auth_flow so concurrent requests within an event loop await the in-progress refresh rather than firing their own. The provider then documents the resulting thread-safety contract explicitly:
Concurrency & Thread Safety:
- SAFE: Concurrent requests within a single asyncio event loop. Token
operations are protected by the parent class''s ``OAuthContext.lock``
via ``async_auth_flow``.
- UNSAFE: Sharing a provider instance across multiple OS threads. Each
thread must instantiate its own provider and event loop.
- Note: Ensure any shared ``TokenStorage`` implementation is async-safe.
Suggested direction
Coalesce concurrent refreshes via SemaphoreSlim so the second-through-Nth caller awaits the first refresh instead of firing their own — matching what Python actually does rather than the silently-undocumented TS/Go status quo. Then document the resulting contract on both providers, similar to Python''s block above.
Whichever way we go, the fix should cover both providers in one pass.
Both
ClientOAuthProvider(_tokenCache,_authServerMetadata) and the newCrossApplicationAccessProvider(_cachedTokens,_resolvedIdpTokenEndpoint, added in #1305) lazily populate mutable instance fields during 401 handling without synchronization.Concurrent challenges are plausible whenever an HTTP-based transport (
StreamableHttpClientTransport,SseClientTransport) has multiple in-flight requests when a token expires — eachHttpClient.SendAsyncis independent, nothing in the transport orMcpSessionlayers serializes them, and each one will independently see 401 and invoke the auth flow. Consequences:TokenContainer, lost metadata).Prior art in other SDKs
TypeScript (
CrossAppAccessProvider._tokens) — plain field with getter/setter, no locking, no documented concurrency contract. Same state we''re in.Go (
EnterpriseHandler.tokenSource) — written fromAuthorizewith no mutex, no documented concurrency contract. Same state we''re in.Python (PR #1721) — coalesces refreshes through the parent
OAuthClientProvider''sOAuthContext.lock, taken insideasync_auth_flowso concurrent requests within an event loop await the in-progress refresh rather than firing their own. The provider then documents the resulting thread-safety contract explicitly:Suggested direction
Coalesce concurrent refreshes via
SemaphoreSlimso the second-through-Nth caller awaits the first refresh instead of firing their own — matching what Python actually does rather than the silently-undocumented TS/Go status quo. Then document the resulting contract on both providers, similar to Python''s block above.Whichever way we go, the fix should cover both providers in one pass.