@@ -150,7 +150,7 @@ export interface OAuthClientProvider {
150150 * credentials, in the case where the server has indicated that they are no longer valid.
151151 * This avoids requiring the user to intervene manually.
152152 */
153- invalidateCredentials ?( scope : 'all' | 'client' | 'tokens' | 'verifier' ) : void | Promise < void > ;
153+ invalidateCredentials ?( scope : 'all' | 'client' | 'tokens' | 'verifier' | 'discovery' ) : void | Promise < void > ;
154154
155155 /**
156156 * Prepares grant-specific parameters for a token request.
@@ -189,6 +189,46 @@ export interface OAuthClientProvider {
189189 * }
190190 */
191191 prepareTokenRequest ?( scope ?: string ) : URLSearchParams | Promise < URLSearchParams | undefined > | undefined ;
192+
193+ /**
194+ * Saves the OAuth discovery state after RFC 9728 and authorization server metadata
195+ * discovery. Providers can persist this state to avoid redundant discovery requests
196+ * on subsequent {@linkcode auth} calls.
197+ *
198+ * This state can also be provided out-of-band (e.g., from a previous session or
199+ * external configuration) to bootstrap the OAuth flow without discovery.
200+ *
201+ * Called by {@linkcode auth} after successful discovery.
202+ */
203+ saveDiscoveryState ?( state : OAuthDiscoveryState ) : void | Promise < void > ;
204+
205+ /**
206+ * Returns previously saved discovery state, or `undefined` if none is cached.
207+ *
208+ * When available, {@linkcode auth} restores the discovery state (authorization server
209+ * URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing
210+ * latency on subsequent calls.
211+ *
212+ * Providers should clear cached discovery state on repeated authentication failures
213+ * (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow
214+ * re-discovery in case the authorization server has changed.
215+ */
216+ discoveryState ?( ) : OAuthDiscoveryState | undefined | Promise < OAuthDiscoveryState | undefined > ;
217+ }
218+
219+ /**
220+ * Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}.
221+ *
222+ * Contains the results of RFC 9728 protected resource metadata discovery and
223+ * authorization server metadata discovery. Persisting this state avoids
224+ * redundant discovery HTTP requests on subsequent {@linkcode auth} calls.
225+ */
226+ // TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL
227+ // at which authorization server metadata was discovered. This would require
228+ // `discoverAuthorizationServerMetadata()` to return the successful discovery URL.
229+ export interface OAuthDiscoveryState extends OAuthServerInfo {
230+ /** The URL at which the protected resource metadata was found, if available. */
231+ resourceMetadataUrl ?: string ;
192232}
193233
194234export type AuthResult = 'AUTHORIZED' | 'REDIRECT' ;
@@ -397,32 +437,70 @@ async function authInternal(
397437 fetchFn ?: FetchLike ;
398438 }
399439) : Promise < AuthResult > {
440+ // Check if the provider has cached discovery state to skip discovery
441+ const cachedState = await provider . discoveryState ?.( ) ;
442+
400443 let resourceMetadata : OAuthProtectedResourceMetadata | undefined ;
401- let authorizationServerUrl : string | URL | undefined ;
444+ let authorizationServerUrl : string | URL ;
445+ let metadata : AuthorizationServerMetadata | undefined ;
446+
447+ // If resourceMetadataUrl is not provided, try to load it from cached state
448+ // This handles browser redirects where the URL was saved before navigation
449+ let effectiveResourceMetadataUrl = resourceMetadataUrl ;
450+ if ( ! effectiveResourceMetadataUrl && cachedState ?. resourceMetadataUrl ) {
451+ effectiveResourceMetadataUrl = new URL ( cachedState . resourceMetadataUrl ) ;
452+ }
402453
403- try {
404- resourceMetadata = await discoverOAuthProtectedResourceMetadata ( serverUrl , { resourceMetadataUrl } , fetchFn ) ;
405- if ( resourceMetadata . authorization_servers && resourceMetadata . authorization_servers . length > 0 ) {
406- authorizationServerUrl = resourceMetadata . authorization_servers [ 0 ] ;
454+ if ( cachedState ?. authorizationServerUrl ) {
455+ // Restore discovery state from cache
456+ authorizationServerUrl = cachedState . authorizationServerUrl ;
457+ resourceMetadata = cachedState . resourceMetadata ;
458+ metadata =
459+ cachedState . authorizationServerMetadata ?? ( await discoverAuthorizationServerMetadata ( authorizationServerUrl , { fetchFn } ) ) ;
460+
461+ // If resource metadata wasn't cached, try to fetch it for selectResourceURL
462+ if ( ! resourceMetadata ) {
463+ try {
464+ resourceMetadata = await discoverOAuthProtectedResourceMetadata (
465+ serverUrl ,
466+ { resourceMetadataUrl : effectiveResourceMetadataUrl } ,
467+ fetchFn
468+ ) ;
469+ } catch {
470+ // RFC 9728 not available — selectResourceURL will handle undefined
471+ }
407472 }
408- } catch {
409- // Ignore errors and fall back to /.well-known/oauth-authorization-server
410- }
411473
412- /**
413- * If we don't get a valid authorization server metadata from protected resource metadata,
414- * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server.
415- */
416- if ( ! authorizationServerUrl ) {
417- authorizationServerUrl = new URL ( '/' , serverUrl ) ;
474+ // Re-save if we enriched the cached state with missing metadata
475+ if ( metadata !== cachedState . authorizationServerMetadata || resourceMetadata !== cachedState . resourceMetadata ) {
476+ await provider . saveDiscoveryState ?.( {
477+ authorizationServerUrl : String ( authorizationServerUrl ) ,
478+ resourceMetadataUrl : effectiveResourceMetadataUrl ?. toString ( ) ,
479+ resourceMetadata,
480+ authorizationServerMetadata : metadata
481+ } ) ;
482+ }
483+ } else {
484+ // Full discovery via RFC 9728
485+ const serverInfo = await discoverOAuthServerInfo ( serverUrl , { resourceMetadataUrl : effectiveResourceMetadataUrl , fetchFn } ) ;
486+ authorizationServerUrl = serverInfo . authorizationServerUrl ;
487+ metadata = serverInfo . authorizationServerMetadata ;
488+ resourceMetadata = serverInfo . resourceMetadata ;
489+
490+ // Persist discovery state for future use
491+ // TODO: resourceMetadataUrl is only populated when explicitly provided via options
492+ // or loaded from cached state. The URL derived internally by
493+ // discoverOAuthProtectedResourceMetadata() is not captured back here.
494+ await provider . saveDiscoveryState ?.( {
495+ authorizationServerUrl : String ( authorizationServerUrl ) ,
496+ resourceMetadataUrl : effectiveResourceMetadataUrl ?. toString ( ) ,
497+ resourceMetadata,
498+ authorizationServerMetadata : metadata
499+ } ) ;
418500 }
419501
420502 const resource : URL | undefined = await selectResourceURL ( serverUrl , provider , resourceMetadata ) ;
421503
422- const metadata = await discoverAuthorizationServerMetadata ( authorizationServerUrl , {
423- fetchFn
424- } ) ;
425-
426504 // Handle client registration if needed
427505 let clientInformation = await Promise . resolve ( provider . clientInformation ( ) ) ;
428506 if ( ! clientInformation ) {
@@ -885,6 +963,87 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url:
885963 * @param options.protocolVersion - MCP protocol version to use, defaults to LATEST_PROTOCOL_VERSION
886964 * @returns Promise resolving to authorization server metadata, or undefined if discovery fails
887965 */
966+ /**
967+ * Result of {@linkcode discoverOAuthServerInfo}.
968+ */
969+ export interface OAuthServerInfo {
970+ /**
971+ * The authorization server URL, either discovered via RFC 9728
972+ * or derived from the MCP server URL as a fallback.
973+ */
974+ authorizationServerUrl : string ;
975+
976+ /**
977+ * The authorization server metadata (endpoints, capabilities),
978+ * or `undefined` if metadata discovery failed.
979+ */
980+ authorizationServerMetadata ?: AuthorizationServerMetadata ;
981+
982+ /**
983+ * The OAuth 2.0 Protected Resource Metadata from RFC 9728,
984+ * or `undefined` if the server does not support it.
985+ */
986+ resourceMetadata ?: OAuthProtectedResourceMetadata ;
987+ }
988+
989+ /**
990+ * Discovers the authorization server for an MCP server following
991+ * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected
992+ * Resource Metadata), with fallback to treating the server URL as the
993+ * authorization server.
994+ *
995+ * This function combines two discovery steps into one call:
996+ * 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the
997+ * authorization server URL (RFC 9728).
998+ * 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery).
999+ *
1000+ * Use this when you need the authorization server metadata for operations outside the
1001+ * {@linkcode auth} orchestrator, such as token refresh or token revocation.
1002+ *
1003+ * @param serverUrl - The MCP resource server URL
1004+ * @param opts - Optional configuration
1005+ * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint
1006+ * @param opts.fetchFn - Custom fetch function for HTTP requests
1007+ * @returns Authorization server URL, metadata, and resource metadata (if available)
1008+ */
1009+ export async function discoverOAuthServerInfo (
1010+ serverUrl : string | URL ,
1011+ opts ?: {
1012+ resourceMetadataUrl ?: URL ;
1013+ fetchFn ?: FetchLike ;
1014+ }
1015+ ) : Promise < OAuthServerInfo > {
1016+ let resourceMetadata : OAuthProtectedResourceMetadata | undefined ;
1017+ let authorizationServerUrl : string | undefined ;
1018+
1019+ try {
1020+ resourceMetadata = await discoverOAuthProtectedResourceMetadata (
1021+ serverUrl ,
1022+ { resourceMetadataUrl : opts ?. resourceMetadataUrl } ,
1023+ opts ?. fetchFn
1024+ ) ;
1025+ if ( resourceMetadata . authorization_servers && resourceMetadata . authorization_servers . length > 0 ) {
1026+ authorizationServerUrl = resourceMetadata . authorization_servers [ 0 ] ;
1027+ }
1028+ } catch {
1029+ // RFC 9728 not supported -- fall back to treating the server URL as the authorization server
1030+ }
1031+
1032+ // If we don't get a valid authorization server from protected resource metadata,
1033+ // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server
1034+ if ( ! authorizationServerUrl ) {
1035+ authorizationServerUrl = String ( new URL ( '/' , serverUrl ) ) ;
1036+ }
1037+
1038+ const authorizationServerMetadata = await discoverAuthorizationServerMetadata ( authorizationServerUrl , { fetchFn : opts ?. fetchFn } ) ;
1039+
1040+ return {
1041+ authorizationServerUrl,
1042+ authorizationServerMetadata,
1043+ resourceMetadata
1044+ } ;
1045+ }
1046+
8881047export async function discoverAuthorizationServerMetadata (
8891048 authorizationServerUrl : string | URL ,
8901049 {
0 commit comments