Skip to content

Commit 06ad778

Browse files
BabyChrist666claude
andcommitted
fix: restore eager OAuth discovery to avoid slow unauthenticated roundtrip (#1274)
When the client has no valid tokens, perform OAuth discovery and authorization BEFORE sending the MCP request. This restores the eager behavior from v1.11.0 that was removed in v1.12.0, eliminating the unnecessary unauthenticated roundtrip that servers like Notion handle slowly (~10s latency per operation). Both the eager (pre-request) and reactive (post-401) paths now share a single `_perform_oauth_discovery_and_auth()` helper, keeping the code DRY while preserving RFC 9728 WWW-Authenticate header support on the 401 path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0fe16dd commit 06ad778

File tree

3 files changed

+303
-99
lines changed

3 files changed

+303
-99
lines changed

src/mcp/client/auth/oauth2.py

Lines changed: 131 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,102 @@ async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None
497497
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
498498
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
499499

500+
async def _perform_oauth_discovery_and_auth(
501+
self,
502+
www_auth_response: httpx.Response | None = None,
503+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
504+
"""Perform the full OAuth discovery, registration, and authorization flow.
505+
506+
This is extracted as a helper to allow both eager (pre-request) and
507+
reactive (post-401) OAuth flows to share the same implementation.
508+
509+
Args:
510+
www_auth_response: Optional 401 response to extract WWW-Authenticate
511+
header from for RFC 9728 resource_metadata discovery. When None,
512+
falls back to well-known URL discovery.
513+
"""
514+
www_auth_resource_metadata_url = (
515+
extract_resource_metadata_from_www_auth(www_auth_response) if www_auth_response else None
516+
)
517+
518+
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
519+
prm_discovery_urls = build_protected_resource_metadata_discovery_urls(
520+
www_auth_resource_metadata_url, self.context.server_url
521+
)
522+
523+
for url in prm_discovery_urls: # pragma: no branch
524+
discovery_request = create_oauth_metadata_request(url)
525+
526+
discovery_response = yield discovery_request # sending request
527+
528+
prm = await handle_protected_resource_response(discovery_response)
529+
if prm:
530+
# Validate PRM resource matches server URL (RFC 8707)
531+
await self._validate_resource_match(prm)
532+
self.context.protected_resource_metadata = prm
533+
534+
# todo: try all authorization_servers to find the OASM
535+
assert (
536+
len(prm.authorization_servers) > 0
537+
) # this is always true as authorization_servers has a min length of 1
538+
539+
self.context.auth_server_url = str(prm.authorization_servers[0])
540+
break
541+
else:
542+
logger.debug(f"Protected resource metadata discovery failed: {url}")
543+
544+
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
545+
self.context.auth_server_url, self.context.server_url
546+
)
547+
548+
# Step 2: Discover OAuth Authorization Server Metadata (OASM)
549+
for url in asm_discovery_urls: # pragma: no branch
550+
oauth_metadata_request = create_oauth_metadata_request(url)
551+
oauth_metadata_response = yield oauth_metadata_request
552+
553+
ok, asm = await handle_auth_metadata_response(oauth_metadata_response)
554+
if not ok:
555+
break
556+
if ok and asm:
557+
self.context.oauth_metadata = asm
558+
break
559+
else:
560+
logger.debug(f"OAuth metadata discovery failed: {url}")
561+
562+
# Step 3: Apply scope selection strategy
563+
self.context.client_metadata.scope = get_client_metadata_scopes(
564+
extract_scope_from_www_auth(www_auth_response) if www_auth_response else None,
565+
self.context.protected_resource_metadata,
566+
self.context.oauth_metadata,
567+
)
568+
569+
# Step 4: Register client or use URL-based client ID (CIMD)
570+
if not self.context.client_info:
571+
if should_use_client_metadata_url(self.context.oauth_metadata, self.context.client_metadata_url):
572+
# Use URL-based client ID (CIMD)
573+
logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}")
574+
client_information = create_client_info_from_metadata_url(
575+
self.context.client_metadata_url, # type: ignore[arg-type]
576+
redirect_uris=self.context.client_metadata.redirect_uris,
577+
)
578+
self.context.client_info = client_information
579+
await self.context.storage.set_client_info(client_information)
580+
else:
581+
# Fallback to Dynamic Client Registration
582+
registration_request = create_client_registration_request(
583+
self.context.oauth_metadata,
584+
self.context.client_metadata,
585+
self.context.get_authorization_base_url(self.context.server_url),
586+
)
587+
registration_response = yield registration_request
588+
client_information = await handle_registration_response(registration_response)
589+
self.context.client_info = client_information
590+
await self.context.storage.set_client_info(client_information)
591+
592+
# Step 5: Perform authorization and complete token exchange
593+
token_response = yield await self._perform_authorization()
594+
await self._handle_token_response(token_response)
595+
500596
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
501597
"""HTTPX auth flow integration."""
502598
async with self.context.lock:
@@ -515,96 +611,48 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
515611
# Refresh failed, need full re-authentication
516612
self._initialized = False
517613

614+
# Eager OAuth: if we have no valid token, can't refresh, AND we have
615+
# already been through the OAuth flow at least once (we have
616+
# client_info and discovery metadata), re-run the discovery/auth flow
617+
# BEFORE sending the MCP request. This avoids the unnecessary
618+
# unauthenticated round-trip that some servers (e.g. Notion) handle
619+
# slowly, causing ~10 s latency per request. See #1274.
620+
#
621+
# On the very first connection (no client_info), we skip the eager
622+
# flow and let the reactive 401 path handle discovery, because the
623+
# server's WWW-Authenticate header may carry routing information
624+
# (e.g. resource_metadata URL) that pure well-known discovery lacks.
625+
#
626+
# If the eager flow fails, we fall through gracefully and send the
627+
# MCP request without auth so the reactive 401 path can take over.
628+
if not self.context.is_token_valid() and self.context.client_info:
629+
try:
630+
oauth_gen = self._perform_oauth_discovery_and_auth()
631+
oauth_request = await oauth_gen.__anext__()
632+
while True:
633+
oauth_response = yield oauth_request
634+
oauth_request = await oauth_gen.asend(oauth_response)
635+
except StopAsyncIteration:
636+
pass
637+
except Exception:
638+
logger.debug("Eager OAuth discovery failed, falling back to reactive 401 path", exc_info=True)
639+
518640
if self.context.is_token_valid():
519641
self._add_auth_header(request)
520642

521643
response = yield request
522644

523645
if response.status_code == 401:
524-
# Perform full OAuth flow
646+
# Perform full OAuth flow (reactive path — uses WWW-Authenticate
647+
# header from the 401 response for RFC 9728 discovery)
525648
try:
526-
# OAuth flow must be inline due to generator constraints
527-
www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response)
528-
529-
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
530-
prm_discovery_urls = build_protected_resource_metadata_discovery_urls(
531-
www_auth_resource_metadata_url, self.context.server_url
532-
)
533-
534-
for url in prm_discovery_urls: # pragma: no branch
535-
discovery_request = create_oauth_metadata_request(url)
536-
537-
discovery_response = yield discovery_request # sending request
538-
539-
prm = await handle_protected_resource_response(discovery_response)
540-
if prm:
541-
# Validate PRM resource matches server URL (RFC 8707)
542-
await self._validate_resource_match(prm)
543-
self.context.protected_resource_metadata = prm
544-
545-
# todo: try all authorization_servers to find the OASM
546-
assert (
547-
len(prm.authorization_servers) > 0
548-
) # this is always true as authorization_servers has a min length of 1
549-
550-
self.context.auth_server_url = str(prm.authorization_servers[0])
551-
break
552-
else:
553-
logger.debug(f"Protected resource metadata discovery failed: {url}")
554-
555-
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
556-
self.context.auth_server_url, self.context.server_url
557-
)
558-
559-
# Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers)
560-
for url in asm_discovery_urls: # pragma: no branch
561-
oauth_metadata_request = create_oauth_metadata_request(url)
562-
oauth_metadata_response = yield oauth_metadata_request
563-
564-
ok, asm = await handle_auth_metadata_response(oauth_metadata_response)
565-
if not ok:
566-
break
567-
if ok and asm:
568-
self.context.oauth_metadata = asm
569-
break
570-
else:
571-
logger.debug(f"OAuth metadata discovery failed: {url}")
572-
573-
# Step 3: Apply scope selection strategy
574-
self.context.client_metadata.scope = get_client_metadata_scopes(
575-
extract_scope_from_www_auth(response),
576-
self.context.protected_resource_metadata,
577-
self.context.oauth_metadata,
578-
)
579-
580-
# Step 4: Register client or use URL-based client ID (CIMD)
581-
if not self.context.client_info:
582-
if should_use_client_metadata_url(
583-
self.context.oauth_metadata, self.context.client_metadata_url
584-
):
585-
# Use URL-based client ID (CIMD)
586-
logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}")
587-
client_information = create_client_info_from_metadata_url(
588-
self.context.client_metadata_url, # type: ignore[arg-type]
589-
redirect_uris=self.context.client_metadata.redirect_uris,
590-
)
591-
self.context.client_info = client_information
592-
await self.context.storage.set_client_info(client_information)
593-
else:
594-
# Fallback to Dynamic Client Registration
595-
registration_request = create_client_registration_request(
596-
self.context.oauth_metadata,
597-
self.context.client_metadata,
598-
self.context.get_authorization_base_url(self.context.server_url),
599-
)
600-
registration_response = yield registration_request
601-
client_information = await handle_registration_response(registration_response)
602-
self.context.client_info = client_information
603-
await self.context.storage.set_client_info(client_information)
604-
605-
# Step 5: Perform authorization and complete token exchange
606-
token_response = yield await self._perform_authorization()
607-
await self._handle_token_response(token_response)
649+
oauth_gen = self._perform_oauth_discovery_and_auth(www_auth_response=response)
650+
oauth_request = await oauth_gen.__anext__()
651+
while True:
652+
oauth_response = yield oauth_request
653+
oauth_request = await oauth_gen.asend(oauth_response)
654+
except StopAsyncIteration:
655+
pass
608656
except Exception: # pragma: no cover
609657
logger.exception("OAuth flow error")
610658
raise

0 commit comments

Comments
 (0)