@@ -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