Skip to content

Commit 35777b9

Browse files
fix: perform auth server metadata discovery fallbacks on any 4xx (#1193)
1 parent 6d2e6d4 commit 35777b9

File tree

2 files changed

+105
-2
lines changed

2 files changed

+105
-2
lines changed

src/mcp/client/auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -526,8 +526,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
526526
break
527527
except ValidationError:
528528
continue
529-
elif oauth_metadata_response.status_code != 404:
530-
break # Non-404 error, stop trying
529+
elif oauth_metadata_response.status_code < 400 or oauth_metadata_response.status_code >= 500:
530+
break # Non-4XX error, stop trying
531531

532532
# Step 3: Register client if needed
533533
registration_request = await self._register_client()

tests/client/test_auth.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,109 @@ async def test_oauth_discovery_fallback_order(self, oauth_provider):
261261
"https://api.example.com/v1/mcp/.well-known/openid-configuration",
262262
]
263263

264+
@pytest.mark.anyio
265+
async def test_oauth_discovery_fallback_conditions(self, oauth_provider):
266+
"""Test the conditions during which an AS metadata discovery fallback will be attempted."""
267+
# Ensure no tokens are stored
268+
oauth_provider.context.current_tokens = None
269+
oauth_provider.context.token_expiry_time = None
270+
oauth_provider._initialized = True
271+
272+
# Mock client info to skip DCR
273+
oauth_provider.context.client_info = OAuthClientInformationFull(
274+
client_id="existing_client",
275+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
276+
)
277+
278+
# Create a test request
279+
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
280+
281+
# Mock the auth flow
282+
auth_flow = oauth_provider.async_auth_flow(test_request)
283+
284+
# First request should be the original request without auth header
285+
request = await auth_flow.__anext__()
286+
assert "Authorization" not in request.headers
287+
288+
# Send a 401 response to trigger the OAuth flow
289+
response = httpx.Response(
290+
401,
291+
headers={
292+
"WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
293+
},
294+
request=test_request,
295+
)
296+
297+
# Next request should be to discover protected resource metadata
298+
discovery_request = await auth_flow.asend(response)
299+
assert str(discovery_request.url) == "https://api.example.com/.well-known/oauth-protected-resource"
300+
assert discovery_request.method == "GET"
301+
302+
# Send a successful discovery response with minimal protected resource metadata
303+
discovery_response = httpx.Response(
304+
200,
305+
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com/v1/mcp"]}',
306+
request=discovery_request,
307+
)
308+
309+
# Next request should be to discover OAuth metadata
310+
oauth_metadata_request_1 = await auth_flow.asend(discovery_response)
311+
assert (
312+
str(oauth_metadata_request_1.url)
313+
== "https://auth.example.com/.well-known/oauth-authorization-server/v1/mcp"
314+
)
315+
assert oauth_metadata_request_1.method == "GET"
316+
317+
# Send a 404 response
318+
oauth_metadata_response_1 = httpx.Response(
319+
404,
320+
content=b"Not Found",
321+
request=oauth_metadata_request_1,
322+
)
323+
324+
# Next request should be to discover OAuth metadata at the next endpoint
325+
oauth_metadata_request_2 = await auth_flow.asend(oauth_metadata_response_1)
326+
assert str(oauth_metadata_request_2.url) == "https://auth.example.com/.well-known/oauth-authorization-server"
327+
assert oauth_metadata_request_2.method == "GET"
328+
329+
# Send a 400 response
330+
oauth_metadata_response_2 = httpx.Response(
331+
400,
332+
content=b"Bad Request",
333+
request=oauth_metadata_request_2,
334+
)
335+
336+
# Next request should be to discover OAuth metadata at the next endpoint
337+
oauth_metadata_request_3 = await auth_flow.asend(oauth_metadata_response_2)
338+
assert str(oauth_metadata_request_3.url) == "https://auth.example.com/.well-known/openid-configuration/v1/mcp"
339+
assert oauth_metadata_request_3.method == "GET"
340+
341+
# Send a 500 response
342+
oauth_metadata_response_3 = httpx.Response(
343+
500,
344+
content=b"Internal Server Error",
345+
request=oauth_metadata_request_3,
346+
)
347+
348+
# Mock the authorization process to minimize unnecessary state in this test
349+
oauth_provider._perform_authorization = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier"))
350+
351+
# Next request should fall back to legacy behavior and auth with the RS (mocked /authorize, next is /token)
352+
token_request = await auth_flow.asend(oauth_metadata_response_3)
353+
assert str(token_request.url) == "https://api.example.com/token"
354+
assert token_request.method == "POST"
355+
356+
# Send a successful token response
357+
token_response = httpx.Response(
358+
200,
359+
content=(
360+
b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, '
361+
b'"refresh_token": "new_refresh_token"}'
362+
),
363+
request=token_request,
364+
)
365+
token_request = await auth_flow.asend(token_response)
366+
264367
@pytest.mark.anyio
265368
async def test_handle_metadata_response_success(self, oauth_provider):
266369
"""Test successful metadata response handling."""

0 commit comments

Comments
 (0)