Skip to content

Commit dc896e1

Browse files
feat: expose discoverOAuthServerInfo() and add provider caching for auth server URL (modelcontextprotocol#1527)
Co-authored-by: hassan123789 <49031989+hassan123789@users.noreply.github.com>
1 parent 288eaf8 commit dc896e1

File tree

3 files changed

+552
-19
lines changed

3 files changed

+552
-19
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
Add `discoverOAuthServerInfo()` function and unified discovery state caching for OAuth
6+
7+
- New `discoverOAuthServerInfo(serverUrl)` export that performs RFC 9728 protected resource metadata discovery followed by authorization server metadata discovery in a single call. Use this for operations like token refresh and revocation that need the authorization server URL outside of `auth()`.
8+
- New `OAuthDiscoveryState` type and optional `OAuthClientProvider` methods `saveDiscoveryState()` / `discoveryState()` allow providers to persist all discovery results (auth server URL, resource metadata URL, resource metadata, auth server metadata) across sessions. This avoids redundant discovery requests and handles browser redirect scenarios where discovery state would otherwise be lost.
9+
- New `'discovery'` scope for `invalidateCredentials()` to clear cached discovery state.
10+
- New `OAuthServerInfo` type exported for the return value of `discoverOAuthServerInfo()`.

packages/client/src/client/auth.ts

Lines changed: 178 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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

188228
export 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

Comments
 (0)