@@ -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 ) {
@@ -937,6 +1015,87 @@ export async function discoverAuthorizationServerMetadata(
9371015 return undefined ;
9381016}
9391017
1018+ /**
1019+ * Result of {@linkcode discoverOAuthServerInfo}.
1020+ */
1021+ export interface OAuthServerInfo {
1022+ /**
1023+ * The authorization server URL, either discovered via RFC 9728
1024+ * or derived from the MCP server URL as a fallback.
1025+ */
1026+ authorizationServerUrl : string ;
1027+
1028+ /**
1029+ * The authorization server metadata (endpoints, capabilities),
1030+ * or `undefined` if metadata discovery failed.
1031+ */
1032+ authorizationServerMetadata ?: AuthorizationServerMetadata ;
1033+
1034+ /**
1035+ * The OAuth 2.0 Protected Resource Metadata from RFC 9728,
1036+ * or `undefined` if the server does not support it.
1037+ */
1038+ resourceMetadata ?: OAuthProtectedResourceMetadata ;
1039+ }
1040+
1041+ /**
1042+ * Discovers the authorization server for an MCP server following
1043+ * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected
1044+ * Resource Metadata), with fallback to treating the server URL as the
1045+ * authorization server.
1046+ *
1047+ * This function combines two discovery steps into one call:
1048+ * 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the
1049+ * authorization server URL (RFC 9728).
1050+ * 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery).
1051+ *
1052+ * Use this when you need the authorization server metadata for operations outside the
1053+ * {@linkcode auth} orchestrator, such as token refresh or token revocation.
1054+ *
1055+ * @param serverUrl - The MCP resource server URL
1056+ * @param opts - Optional configuration
1057+ * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint
1058+ * @param opts.fetchFn - Custom fetch function for HTTP requests
1059+ * @returns Authorization server URL, metadata, and resource metadata (if available)
1060+ */
1061+ export async function discoverOAuthServerInfo (
1062+ serverUrl : string | URL ,
1063+ opts ?: {
1064+ resourceMetadataUrl ?: URL ;
1065+ fetchFn ?: FetchLike ;
1066+ }
1067+ ) : Promise < OAuthServerInfo > {
1068+ let resourceMetadata : OAuthProtectedResourceMetadata | undefined ;
1069+ let authorizationServerUrl : string | undefined ;
1070+
1071+ try {
1072+ resourceMetadata = await discoverOAuthProtectedResourceMetadata (
1073+ serverUrl ,
1074+ { resourceMetadataUrl : opts ?. resourceMetadataUrl } ,
1075+ opts ?. fetchFn
1076+ ) ;
1077+ if ( resourceMetadata . authorization_servers && resourceMetadata . authorization_servers . length > 0 ) {
1078+ authorizationServerUrl = resourceMetadata . authorization_servers [ 0 ] ;
1079+ }
1080+ } catch {
1081+ // RFC 9728 not supported -- fall back to treating the server URL as the authorization server
1082+ }
1083+
1084+ // If we don't get a valid authorization server from protected resource metadata,
1085+ // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server
1086+ if ( ! authorizationServerUrl ) {
1087+ authorizationServerUrl = String ( new URL ( '/' , serverUrl ) ) ;
1088+ }
1089+
1090+ const authorizationServerMetadata = await discoverAuthorizationServerMetadata ( authorizationServerUrl , { fetchFn : opts ?. fetchFn } ) ;
1091+
1092+ return {
1093+ authorizationServerUrl,
1094+ authorizationServerMetadata,
1095+ resourceMetadata
1096+ } ;
1097+ }
1098+
9401099/**
9411100 * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
9421101 */
0 commit comments