@@ -144,7 +144,7 @@ export interface OAuthClientProvider {
144144 * credentials, in the case where the server has indicated that they are no longer valid.
145145 * This avoids requiring the user to intervene manually.
146146 */
147- invalidateCredentials ?( scope : 'all' | 'client' | 'tokens' | 'verifier' ) : void | Promise < void > ;
147+ invalidateCredentials ?( scope : 'all' | 'client' | 'tokens' | 'verifier' | 'discovery' ) : void | Promise < void > ;
148148
149149 /**
150150 * Prepares grant-specific parameters for a token request.
@@ -183,6 +183,46 @@ export interface OAuthClientProvider {
183183 * }
184184 */
185185 prepareTokenRequest ?( scope ?: string ) : URLSearchParams | Promise < URLSearchParams | undefined > | undefined ;
186+
187+ /**
188+ * Saves the OAuth discovery state after RFC 9728 and authorization server metadata
189+ * discovery. Providers can persist this state to avoid redundant discovery requests
190+ * on subsequent {@linkcode auth} calls.
191+ *
192+ * This state can also be provided out-of-band (e.g., from a previous session or
193+ * external configuration) to bootstrap the OAuth flow without discovery.
194+ *
195+ * Called by {@linkcode auth} after successful discovery.
196+ */
197+ saveDiscoveryState ?( state : OAuthDiscoveryState ) : void | Promise < void > ;
198+
199+ /**
200+ * Returns previously saved discovery state, or `undefined` if none is cached.
201+ *
202+ * When available, {@linkcode auth} restores the discovery state (authorization server
203+ * URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing
204+ * latency on subsequent calls.
205+ *
206+ * Providers should clear cached discovery state on repeated authentication failures
207+ * (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow
208+ * re-discovery in case the authorization server has changed.
209+ */
210+ discoveryState ?( ) : OAuthDiscoveryState | undefined | Promise < OAuthDiscoveryState | undefined > ;
211+ }
212+
213+ /**
214+ * Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}.
215+ *
216+ * Contains the results of RFC 9728 protected resource metadata discovery and
217+ * authorization server metadata discovery. Persisting this state avoids
218+ * redundant discovery HTTP requests on subsequent {@linkcode auth} calls.
219+ */
220+ // TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL
221+ // at which authorization server metadata was discovered. This would require
222+ // `discoverAuthorizationServerMetadata()` to return the successful discovery URL.
223+ export interface OAuthDiscoveryState extends OAuthServerInfo {
224+ /** The URL at which the protected resource metadata was found, if available. */
225+ resourceMetadataUrl ?: string ;
186226}
187227
188228export type AuthResult = 'AUTHORIZED' | 'REDIRECT' ;
@@ -395,32 +435,70 @@ async function authInternal(
395435 fetchFn ?: FetchLike ;
396436 }
397437) : Promise < AuthResult > {
438+ // Check if the provider has cached discovery state to skip discovery
439+ const cachedState = await provider . discoveryState ?.( ) ;
440+
398441 let resourceMetadata : OAuthProtectedResourceMetadata | undefined ;
399- let authorizationServerUrl : string | URL | undefined ;
442+ let authorizationServerUrl : string | URL ;
443+ let metadata : AuthorizationServerMetadata | undefined ;
444+
445+ // If resourceMetadataUrl is not provided, try to load it from cached state
446+ // This handles browser redirects where the URL was saved before navigation
447+ let effectiveResourceMetadataUrl = resourceMetadataUrl ;
448+ if ( ! effectiveResourceMetadataUrl && cachedState ?. resourceMetadataUrl ) {
449+ effectiveResourceMetadataUrl = new URL ( cachedState . resourceMetadataUrl ) ;
450+ }
400451
401- try {
402- resourceMetadata = await discoverOAuthProtectedResourceMetadata ( serverUrl , { resourceMetadataUrl } , fetchFn ) ;
403- if ( resourceMetadata . authorization_servers && resourceMetadata . authorization_servers . length > 0 ) {
404- authorizationServerUrl = resourceMetadata . authorization_servers [ 0 ] ;
452+ if ( cachedState ?. authorizationServerUrl ) {
453+ // Restore discovery state from cache
454+ authorizationServerUrl = cachedState . authorizationServerUrl ;
455+ resourceMetadata = cachedState . resourceMetadata ;
456+ metadata =
457+ cachedState . authorizationServerMetadata ?? ( await discoverAuthorizationServerMetadata ( authorizationServerUrl , { fetchFn } ) ) ;
458+
459+ // If resource metadata wasn't cached, try to fetch it for selectResourceURL
460+ if ( ! resourceMetadata ) {
461+ try {
462+ resourceMetadata = await discoverOAuthProtectedResourceMetadata (
463+ serverUrl ,
464+ { resourceMetadataUrl : effectiveResourceMetadataUrl } ,
465+ fetchFn
466+ ) ;
467+ } catch {
468+ // RFC 9728 not available — selectResourceURL will handle undefined
469+ }
405470 }
406- } catch {
407- // Ignore errors and fall back to /.well-known/oauth-authorization-server
408- }
409471
410- /**
411- * If we don't get a valid authorization server metadata from protected resource metadata,
412- * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server.
413- */
414- if ( ! authorizationServerUrl ) {
415- authorizationServerUrl = new URL ( '/' , serverUrl ) ;
472+ // Re-save if we enriched the cached state with missing metadata
473+ if ( metadata !== cachedState . authorizationServerMetadata || resourceMetadata !== cachedState . resourceMetadata ) {
474+ await provider . saveDiscoveryState ?.( {
475+ authorizationServerUrl : String ( authorizationServerUrl ) ,
476+ resourceMetadataUrl : effectiveResourceMetadataUrl ?. toString ( ) ,
477+ resourceMetadata,
478+ authorizationServerMetadata : metadata
479+ } ) ;
480+ }
481+ } else {
482+ // Full discovery via RFC 9728
483+ const serverInfo = await discoverOAuthServerInfo ( serverUrl , { resourceMetadataUrl : effectiveResourceMetadataUrl , fetchFn } ) ;
484+ authorizationServerUrl = serverInfo . authorizationServerUrl ;
485+ metadata = serverInfo . authorizationServerMetadata ;
486+ resourceMetadata = serverInfo . resourceMetadata ;
487+
488+ // Persist discovery state for future use
489+ // TODO: resourceMetadataUrl is only populated when explicitly provided via options
490+ // or loaded from cached state. The URL derived internally by
491+ // discoverOAuthProtectedResourceMetadata() is not captured back here.
492+ await provider . saveDiscoveryState ?.( {
493+ authorizationServerUrl : String ( authorizationServerUrl ) ,
494+ resourceMetadataUrl : effectiveResourceMetadataUrl ?. toString ( ) ,
495+ resourceMetadata,
496+ authorizationServerMetadata : metadata
497+ } ) ;
416498 }
417499
418500 const resource : URL | undefined = await selectResourceURL ( serverUrl , provider , resourceMetadata ) ;
419501
420- const metadata = await discoverAuthorizationServerMetadata ( authorizationServerUrl , {
421- fetchFn
422- } ) ;
423-
424502 // Handle client registration if needed
425503 let clientInformation = await Promise . resolve ( provider . clientInformation ( ) ) ;
426504 if ( ! clientInformation ) {
@@ -941,6 +1019,87 @@ export async function discoverAuthorizationServerMetadata(
9411019 return undefined ;
9421020}
9431021
1022+ /**
1023+ * Result of {@linkcode discoverOAuthServerInfo}.
1024+ */
1025+ export interface OAuthServerInfo {
1026+ /**
1027+ * The authorization server URL, either discovered via RFC 9728
1028+ * or derived from the MCP server URL as a fallback.
1029+ */
1030+ authorizationServerUrl : string ;
1031+
1032+ /**
1033+ * The authorization server metadata (endpoints, capabilities),
1034+ * or `undefined` if metadata discovery failed.
1035+ */
1036+ authorizationServerMetadata ?: AuthorizationServerMetadata ;
1037+
1038+ /**
1039+ * The OAuth 2.0 Protected Resource Metadata from RFC 9728,
1040+ * or `undefined` if the server does not support it.
1041+ */
1042+ resourceMetadata ?: OAuthProtectedResourceMetadata ;
1043+ }
1044+
1045+ /**
1046+ * Discovers the authorization server for an MCP server following
1047+ * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected
1048+ * Resource Metadata), with fallback to treating the server URL as the
1049+ * authorization server.
1050+ *
1051+ * This function combines two discovery steps into one call:
1052+ * 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the
1053+ * authorization server URL (RFC 9728).
1054+ * 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery).
1055+ *
1056+ * Use this when you need the authorization server metadata for operations outside the
1057+ * {@linkcode auth} orchestrator, such as token refresh or token revocation.
1058+ *
1059+ * @param serverUrl - The MCP resource server URL
1060+ * @param opts - Optional configuration
1061+ * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint
1062+ * @param opts.fetchFn - Custom fetch function for HTTP requests
1063+ * @returns Authorization server URL, metadata, and resource metadata (if available)
1064+ */
1065+ export async function discoverOAuthServerInfo (
1066+ serverUrl : string | URL ,
1067+ opts ?: {
1068+ resourceMetadataUrl ?: URL ;
1069+ fetchFn ?: FetchLike ;
1070+ }
1071+ ) : Promise < OAuthServerInfo > {
1072+ let resourceMetadata : OAuthProtectedResourceMetadata | undefined ;
1073+ let authorizationServerUrl : string | undefined ;
1074+
1075+ try {
1076+ resourceMetadata = await discoverOAuthProtectedResourceMetadata (
1077+ serverUrl ,
1078+ { resourceMetadataUrl : opts ?. resourceMetadataUrl } ,
1079+ opts ?. fetchFn
1080+ ) ;
1081+ if ( resourceMetadata . authorization_servers && resourceMetadata . authorization_servers . length > 0 ) {
1082+ authorizationServerUrl = resourceMetadata . authorization_servers [ 0 ] ;
1083+ }
1084+ } catch {
1085+ // RFC 9728 not supported -- fall back to treating the server URL as the authorization server
1086+ }
1087+
1088+ // If we don't get a valid authorization server from protected resource metadata,
1089+ // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server
1090+ if ( ! authorizationServerUrl ) {
1091+ authorizationServerUrl = String ( new URL ( '/' , serverUrl ) ) ;
1092+ }
1093+
1094+ const authorizationServerMetadata = await discoverAuthorizationServerMetadata ( authorizationServerUrl , { fetchFn : opts ?. fetchFn } ) ;
1095+
1096+ return {
1097+ authorizationServerUrl,
1098+ authorizationServerMetadata,
1099+ resourceMetadata
1100+ } ;
1101+ }
1102+
9441103/**
9451104 * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
9461105 */
0 commit comments