@@ -172,7 +172,15 @@ class OAuthContext:
172172 token_expiry_time : float | None = None
173173
174174 # State
175+ #
176+ # `lock` guards short-lived reads and writes of provider state. It is held
177+ # only while mutating that state and is released before the primary HTTP
178+ # request is yielded, so a long-poll GET SSE does not block other requests.
179+ #
180+ # `refresh_lock` single-flights token refresh: one refresh runs, and waiters
181+ # re-check the token cache instead of starting another.
175182 lock : anyio .Lock = field (default_factory = anyio .Lock )
183+ refresh_lock : anyio .Lock = field (default_factory = anyio .Lock )
176184
177185 def get_authorization_base_url (self , server_url : str ) -> str :
178186 """Extract base URL by removing path component."""
@@ -602,33 +610,61 @@ def _expected_issuer(self) -> str:
602610 return self .context .auth_server_url or _origin_issuer (self .context .server_url )
603611
604612 async def _auth_flow (self , request : httpx2 .Request ) -> AsyncGenerator [httpx2 .Request , httpx2 .Response ]:
605- """The OAuth flow proper; `async_auth_flow` drives it (see `RedirectAwareAuth`)."""
613+ """The OAuth flow proper; `async_auth_flow` drives it (see `RedirectAwareAuth`).
614+
615+ ``context.lock`` is held only while reading or mutating provider state.
616+ The primary request yield (which may be a long-poll GET SSE) runs
617+ outside that lock. ``refresh_lock`` single-flights token refresh.
618+ Discovery and registration yields on the 401 path stay inside the lock:
619+ that path is exceptional, and the steady-state request is the one that
620+ must not block other calls.
621+ """
622+ needs_refresh = False
606623 async with self .context .lock :
607624 if not self ._initialized :
608625 await self ._initialize ()
609626
610627 # Capture protocol version from request headers
611628 self .context .protocol_version = request .headers .get (MCP_PROTOCOL_VERSION_HEADER )
612-
613629 if not self .context .is_token_valid () and self .context .can_refresh_token ():
614- # Try to refresh token
615- refresh_request = await self ._refresh_token ()
616- refresh_response = yield refresh_request
617-
618- if not await self ._handle_refresh_response (refresh_response ):
619- # Refresh failed, need full re-authentication
620- self ._initialized = False
630+ needs_refresh = True
631+
632+ if needs_refresh :
633+ async with self .context .refresh_lock :
634+ refresh_request = None
635+ async with self .context .lock :
636+ if not self .context .is_token_valid () and self .context .can_refresh_token ():
637+ refresh_request = await self ._refresh_token ()
638+ if refresh_request is not None :
639+ refresh_response = yield refresh_request
640+ async with self .context .lock :
641+ if not await self ._handle_refresh_response (refresh_response ):
642+ self ._initialized = False
621643
644+ async with self .context .lock :
622645 if self .context .is_token_valid ():
623646 self ._add_auth_header (request )
647+ sent_access_token = self .context .current_tokens .access_token if self .context .current_tokens else None
624648
625- response = yield request
649+ response = yield request
626650
627- step_up = (
628- response .status_code == 403 and extract_field_from_www_auth (response , "error" ) == "insufficient_scope"
629- )
651+ step_up = (
652+ response .status_code == 403 and extract_field_from_www_auth (response , "error" ) == "insufficient_scope"
653+ )
654+
655+ if response .status_code == 401 or step_up :
656+ async with self .context .lock :
657+ current_access_token = self .context .current_tokens .access_token if self .context .current_tokens else None
658+ if (
659+ response .status_code == 401
660+ and current_access_token is not None
661+ and current_access_token != sent_access_token
662+ ):
663+ # Another request refreshed or re-authorized while this one was in flight.
664+ self ._add_auth_header (request )
665+ yield request
666+ return
630667
631- if response .status_code == 401 or step_up :
632668 # Perform full OAuth flow
633669 try :
634670 # Read before discovery, which may clear the tokens: on a restart the stored
0 commit comments