Skip to content

Commit b3273d0

Browse files
committed
fix(oauth): avoid lock contention for long-running requests
1 parent 98b7159 commit b3273d0

2 files changed

Lines changed: 483 additions & 20 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
import secrets
1111
import string
1212
import time
13-
from collections.abc import AsyncGenerator, Awaitable, Callable
13+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
14+
from contextlib import asynccontextmanager
1415
from dataclasses import dataclass, field
1516
from typing import Any, Protocol
1617
from urllib.parse import quote, urlencode, urljoin, urlparse
@@ -115,7 +116,13 @@ class OAuthContext:
115116
token_expiry_time: float | None = None
116117

117118
# State
118-
lock: anyio.Lock = field(default_factory=anyio.Lock)
119+
# Semaphores are intentionally task-agnostic: HTTPX can resume or close an
120+
# auth-flow generator from a different task than the one that yielded it.
121+
# Normal resource requests are still yielded outside these critical sections.
122+
lock: anyio.Semaphore = field(default_factory=lambda: anyio.Semaphore(1, max_value=1))
123+
# Refresh and authorization transitions share one single-flight lock while
124+
# normal resource requests remain independent.
125+
flow_lock: anyio.Semaphore = field(default_factory=lambda: anyio.Semaphore(1, max_value=1))
119126

120127
def get_authorization_base_url(self, server_url: str) -> str:
121128
"""Extract base URL by removing path component."""
@@ -488,30 +495,86 @@ async def _handle_oauth_metadata_response(self, response: httpx.Response) -> Non
488495
metadata = OAuthMetadata.model_validate_json(content)
489496
self.context.oauth_metadata = metadata
490497

491-
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
492-
"""HTTPX auth flow integration."""
498+
async def _prepare_request(self, request: httpx.Request) -> tuple[bool, str | None]:
499+
"""Initialize state and capture request-specific protocol state."""
500+
protocol_version = request.headers.get(MCP_PROTOCOL_VERSION)
493501
async with self.context.lock:
494502
if not self._initialized:
495503
await self._initialize() # pragma: no cover
496504

497-
# Capture protocol version from request headers
498-
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION)
499-
500-
if not self.context.is_token_valid() and self.context.can_refresh_token():
501-
# Try to refresh token
502-
refresh_request = await self._refresh_token() # pragma: no cover
503-
refresh_response = yield refresh_request # pragma: no cover
505+
self.context.protocol_version = protocol_version
506+
needs_refresh = not self.context.is_token_valid() and self.context.can_refresh_token()
507+
return needs_refresh, protocol_version
504508

505-
if not await self._handle_refresh_response(refresh_response): # pragma: no cover
506-
# Refresh failed, need full re-authentication
507-
self._initialized = False
508-
509-
if self.context.is_token_valid():
509+
async def _add_valid_auth_header(self, request: httpx.Request) -> str | None:
510+
"""Add the current valid token and return the token that was sent."""
511+
async with self.context.lock:
512+
current_tokens = self.context.current_tokens
513+
if self.context.is_token_valid() and current_tokens is not None:
510514
self._add_auth_header(request)
515+
return current_tokens.access_token
516+
return None
511517

512-
response = yield request
518+
@asynccontextmanager
519+
async def _serialized_transition(self) -> AsyncIterator[None]:
520+
"""Serialize token-changing OAuth transitions and their state writes."""
521+
async with self.context.flow_lock:
522+
async with self.context.lock:
523+
yield
513524

514-
if response.status_code == 401:
525+
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
526+
"""HTTPX auth flow integration."""
527+
needs_refresh, protocol_version = await self._prepare_request(request)
528+
529+
if needs_refresh:
530+
async with self.context.flow_lock:
531+
refresh_request: httpx.Request | None = None
532+
async with self.context.lock:
533+
self.context.protocol_version = protocol_version
534+
# Another request may have refreshed the token while this
535+
# request was waiting for the single-flight refresh lock.
536+
if not self.context.is_token_valid() and self.context.can_refresh_token():
537+
refresh_request = await self._refresh_token() # pragma: no cover
538+
539+
if refresh_request is not None:
540+
# Do not hold the general provider-state lock across
541+
# network I/O. ``flow_lock`` deliberately remains held to
542+
# keep all token-changing transitions single-flight.
543+
refresh_response = yield refresh_request # pragma: no cover
544+
545+
async with self.context.lock:
546+
if not await self._handle_refresh_response(refresh_response): # pragma: no cover
547+
# Refresh failed, need full re-authentication
548+
self._initialized = False
549+
550+
sent_access_token = await self._add_valid_auth_header(request)
551+
552+
# A GET SSE request can remain open for the session lifetime. Yield it
553+
# outside the state lock so concurrent POST requests can authenticate.
554+
response = yield request
555+
556+
if response.status_code not in (401, 403):
557+
return
558+
559+
# Serialize the exceptional 401/403 state transitions. Their existing
560+
# full authorization flow remains unchanged. Re-check the token only
561+
# after acquiring the lock so concurrent 401 responses cannot start
562+
# redundant authorization flows.
563+
retry_after_authorization = False
564+
async with self._serialized_transition():
565+
self.context.protocol_version = protocol_version
566+
current_tokens = self.context.current_tokens
567+
token_changed_since_request = (
568+
response.status_code in (401, 403)
569+
and self.context.is_token_valid()
570+
and current_tokens is not None
571+
and current_tokens.access_token != sent_access_token
572+
)
573+
574+
if token_changed_since_request:
575+
self._add_auth_header(request)
576+
retry_after_authorization = True
577+
elif response.status_code == 401:
515578
# Perform full OAuth flow
516579
try:
517580
# OAuth flow must be inline due to generator constraints
@@ -602,7 +665,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
602665

603666
# Retry with new tokens
604667
self._add_auth_header(request)
605-
yield request
668+
retry_after_authorization = True
606669
elif response.status_code == 403:
607670
# Step 1: Extract error field from WWW-Authenticate header
608671
error = extract_field_from_www_auth(response, "error")
@@ -624,4 +687,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
624687

625688
# Retry with new tokens
626689
self._add_auth_header(request)
627-
yield request
690+
retry_after_authorization = True
691+
692+
# The retried resource request can itself be a session-long GET.
693+
if retry_after_authorization:
694+
yield request

0 commit comments

Comments
 (0)