Skip to content

Commit 040c7fb

Browse files
feat: backport discoverOAuthServerInfo() and discovery caching to v1.x
Backport of PR #1527 from main to v1.x: - Add discoverOAuthServerInfo() combining RFC 9728 + AS metadata discovery - Add OAuthServerInfo and OAuthDiscoveryState interfaces - Add saveDiscoveryState()/discoveryState() to OAuthClientProvider - Add 'discovery' scope to invalidateCredentials() - Update auth() orchestrator to use cached discovery state - Add comprehensive tests for new functionality
1 parent b0cf837 commit 040c7fb

File tree

3 files changed

+556
-19
lines changed

3 files changed

+556
-19
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@modelcontextprotocol/sdk": minor
3+
---
4+
5+
feat: expose `discoverOAuthServerInfo()` and add provider caching for auth server URL
6+
7+
- Added `discoverOAuthServerInfo()` function that combines RFC 9728 protected resource
8+
metadata discovery with authorization server metadata discovery into a single call.
9+
- Added `OAuthServerInfo` and `OAuthDiscoveryState` interfaces for structured discovery results.
10+
- Added `saveDiscoveryState()` and `discoveryState()` optional methods to `OAuthClientProvider`
11+
for caching discovery results across sessions.
12+
- Added `'discovery'` scope to `invalidateCredentials()` for clearing cached discovery state.
13+
- The `auth()` orchestrator now uses cached discovery state when available, reducing
14+
redundant HTTP requests on subsequent calls.

src/client/auth.ts

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

194234
export 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+
8881047
export async function discoverAuthorizationServerMetadata(
8891048
authorizationServerUrl: string | URL,
8901049
{

0 commit comments

Comments
 (0)