Skip to content

Commit c3c6e59

Browse files
committed
Release context.lock before the protected request is sent
async_auth_flow held context.lock across `response = yield request`, so the lock covered the whole round trip of the protected request instead of just token acquisition. The standalone GET SSE stream goes through the same provider, so it pinned the lock for the lifetime of the stream and the next request, usually the first tools/call, blocked in lock.acquire() until that stream ended. Close the lock before the request is yielded and re-open it around the 401 and 403 re-authorization blocks. Refresh and re-authorization stay serialized; no protected request is sent under the lock. The new test drives two auth flows from two anyio tasks, holds the GET flow at its yield, and requires the POST flow to reach its own yield inside anyio.fail_after(5).
1 parent a4f4ccd commit c3c6e59

2 files changed

Lines changed: 54 additions & 5 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -598,9 +598,13 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
598598
if self.context.is_token_valid():
599599
self._add_auth_header(request)
600600

601-
response = yield request
601+
# Released before the request goes out: the lock serialises token acquisition, and
602+
# holding it for the lifetime of the response would stall every other request on
603+
# this provider until the response ends - unbounded for the standalone GET SSE stream.
604+
response = yield request
602605

603-
if response.status_code == 401:
606+
if response.status_code == 401:
607+
async with self.context.lock:
604608
# Perform full OAuth flow
605609
try:
606610
# OAuth flow must be inline due to generator constraints
@@ -751,8 +755,10 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
751755

752756
# Retry with new tokens
753757
self._add_auth_header(request)
754-
yield request
755-
elif response.status_code == 403:
758+
759+
yield request
760+
elif response.status_code == 403:
761+
async with self.context.lock:
756762
# Step 1: Extract error field from WWW-Authenticate header
757763
error = extract_field_from_www_auth(response, "error")
758764

@@ -782,4 +788,5 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
782788

783789
# Retry with new tokens
784790
self._add_auth_header(request)
785-
yield request
791+
792+
yield request

tests/client/test_auth.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest import mock
77
from urllib.parse import parse_qs, quote, unquote, urlparse
88

9+
import anyio
910
import httpx2
1011
import pytest
1112
from inline_snapshot import Is, snapshot
@@ -3253,3 +3254,44 @@ async def echo_callback() -> AuthorizationCodeResult:
32533254
await auth_flow.asend(httpx2.Response(200, request=final_req))
32543255
except StopAsyncIteration:
32553256
pass
3257+
3258+
3259+
@pytest.mark.anyio
3260+
async def test_in_flight_request_does_not_block_a_concurrent_request(
3261+
oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken
3262+
):
3263+
"""A request still in flight must not hold up the next one on the same provider.
3264+
3265+
The standalone GET SSE stream lives as long as the server keeps it open, so holding
3266+
``context.lock`` until its response arrived stalled the first ``tools/call`` for that
3267+
whole time (#3209).
3268+
"""
3269+
oauth_provider.context.current_tokens = valid_tokens
3270+
oauth_provider.context.token_expiry_time = time.time() + 1800
3271+
oauth_provider._initialized = True
3272+
3273+
sse_sent = anyio.Event()
3274+
call_done = anyio.Event()
3275+
3276+
async def get_sse_stream() -> None:
3277+
flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3278+
request = await flow.__anext__()
3279+
sse_sent.set()
3280+
# The server holds the stream open, so the response lands after the call is answered.
3281+
await call_done.wait()
3282+
with pytest.raises(StopAsyncIteration):
3283+
await flow.asend(httpx2.Response(200, request=request))
3284+
3285+
async def call_tool() -> None:
3286+
await sse_sent.wait()
3287+
flow = oauth_provider.async_auth_flow(httpx2.Request("POST", "https://api.example.com/v1/mcp"))
3288+
with anyio.fail_after(5):
3289+
request = await flow.__anext__()
3290+
assert request.headers["Authorization"] == "Bearer test_access_token"
3291+
with pytest.raises(StopAsyncIteration):
3292+
await flow.asend(httpx2.Response(200, request=request))
3293+
call_done.set()
3294+
3295+
async with anyio.create_task_group() as tg:
3296+
tg.start_soon(get_sse_stream)
3297+
tg.start_soon(call_tool)

0 commit comments

Comments
 (0)