Skip to content

Commit 614b033

Browse files
committed
fix(oauth): release context.lock around the primary request
A long-poll GET SSE no longer holds OAuthContext.lock. Token refresh is single-flight via refresh_lock. A 401 whose access token changed while the request was in flight retries once instead of starting a second authorization. The 401 discovery path stays inside the lock.
1 parent 2af9e30 commit 614b033

1 file changed

Lines changed: 50 additions & 14 deletions

File tree

‎src/mcp/client/auth/oauth2.py‎

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)