Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/expose-auth-server-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': minor
---

Add `discoverOAuthServerInfo()` function and unified discovery state caching for OAuth

- 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()`.
- 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.
- New `'discovery'` scope for `invalidateCredentials()` to clear cached discovery state.
- New `OAuthServerInfo` type exported for the return value of `discoverOAuthServerInfo()`.
197 changes: 178 additions & 19 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export interface OAuthClientProvider {
* credentials, in the case where the server has indicated that they are no longer valid.
* This avoids requiring the user to intervene manually.
*/
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise<void>;
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise<void>;

/**
* Prepares grant-specific parameters for a token request.
Expand Down Expand Up @@ -183,6 +183,46 @@ export interface OAuthClientProvider {
* }
*/
prepareTokenRequest?(scope?: string): URLSearchParams | Promise<URLSearchParams | undefined> | undefined;

/**
* Saves the OAuth discovery state after RFC 9728 and authorization server metadata
* discovery. Providers can persist this state to avoid redundant discovery requests
* on subsequent {@linkcode auth} calls.
*
* This state can also be provided out-of-band (e.g., from a previous session or
* external configuration) to bootstrap the OAuth flow without discovery.
*
* Called by {@linkcode auth} after successful discovery.
*/
saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise<void>;

/**
* Returns previously saved discovery state, or `undefined` if none is cached.
*
* When available, {@linkcode auth} restores the discovery state (authorization server
* URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing
* latency on subsequent calls.
*
* Providers should clear cached discovery state on repeated authentication failures
* (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow
* re-discovery in case the authorization server has changed.
*/
discoveryState?(): OAuthDiscoveryState | undefined | Promise<OAuthDiscoveryState | undefined>;
}

/**
* Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}.
*
* Contains the results of RFC 9728 protected resource metadata discovery and
* authorization server metadata discovery. Persisting this state avoids
* redundant discovery HTTP requests on subsequent {@linkcode auth} calls.
*/
// TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL
// at which authorization server metadata was discovered. This would require
// `discoverAuthorizationServerMetadata()` to return the successful discovery URL.
export interface OAuthDiscoveryState extends OAuthServerInfo {
/** The URL at which the protected resource metadata was found, if available. */
resourceMetadataUrl?: string;
}

export type AuthResult = 'AUTHORIZED' | 'REDIRECT';
Expand Down Expand Up @@ -395,32 +435,70 @@ async function authInternal(
fetchFn?: FetchLike;
}
): Promise<AuthResult> {
// Check if the provider has cached discovery state to skip discovery
const cachedState = await provider.discoveryState?.();

let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl: string | URL | undefined;
let authorizationServerUrl: string | URL;
let metadata: AuthorizationServerMetadata | undefined;

// If resourceMetadataUrl is not provided, try to load it from cached state
// This handles browser redirects where the URL was saved before navigation
let effectiveResourceMetadataUrl = resourceMetadataUrl;
if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) {
effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl);
}

try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn);
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
authorizationServerUrl = resourceMetadata.authorization_servers[0];
if (cachedState?.authorizationServerUrl) {
// Restore discovery state from cache
authorizationServerUrl = cachedState.authorizationServerUrl;
resourceMetadata = cachedState.resourceMetadata;
metadata =
cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn }));

// If resource metadata wasn't cached, try to fetch it for selectResourceURL
if (!resourceMetadata) {
try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
serverUrl,
{ resourceMetadataUrl: effectiveResourceMetadataUrl },
fetchFn
);
} catch {
// RFC 9728 not available — selectResourceURL will handle undefined
}
}
} catch {
// Ignore errors and fall back to /.well-known/oauth-authorization-server
}

/**
* If we don't get a valid authorization server metadata from protected resource metadata,
* fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server.
*/
if (!authorizationServerUrl) {
authorizationServerUrl = new URL('/', serverUrl);
// Re-save if we enriched the cached state with missing metadata
if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) {
await provider.saveDiscoveryState?.({
authorizationServerUrl: String(authorizationServerUrl),
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
resourceMetadata,
authorizationServerMetadata: metadata
});
}
} else {
// Full discovery via RFC 9728
const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn });
authorizationServerUrl = serverInfo.authorizationServerUrl;
metadata = serverInfo.authorizationServerMetadata;
resourceMetadata = serverInfo.resourceMetadata;

// Persist discovery state for future use
// TODO: resourceMetadataUrl is only populated when explicitly provided via options
// or loaded from cached state. The URL derived internally by
// discoverOAuthProtectedResourceMetadata() is not captured back here.
await provider.saveDiscoveryState?.({
authorizationServerUrl: String(authorizationServerUrl),
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
resourceMetadata,
authorizationServerMetadata: metadata
});
}

const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);

const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, {
fetchFn
});

// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
if (!clientInformation) {
Expand Down Expand Up @@ -941,6 +1019,87 @@ export async function discoverAuthorizationServerMetadata(
return undefined;
}

/**
* Result of {@linkcode discoverOAuthServerInfo}.
*/
export interface OAuthServerInfo {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this type and OAuthDiscoveryState be the same?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think they're a little bit different right, they just happen to look similar right now - but in future the OAuthDiscoveryState (what gets saved) could diverge meaningfully from OAuthServerInfo?

To avoid duplication though makes sense for OAuthDiscoveryState to extend OAuthServerInfo though, so changed that here - wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool that makes sense to me

/**
* The authorization server URL, either discovered via RFC 9728
* or derived from the MCP server URL as a fallback.
*/
authorizationServerUrl: string;

/**
* The authorization server metadata (endpoints, capabilities),
* or `undefined` if metadata discovery failed.
*/
authorizationServerMetadata?: AuthorizationServerMetadata;

/**
* The OAuth 2.0 Protected Resource Metadata from RFC 9728,
* or `undefined` if the server does not support it.
*/
resourceMetadata?: OAuthProtectedResourceMetadata;
}

/**
* Discovers the authorization server for an MCP server following
* {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected
* Resource Metadata), with fallback to treating the server URL as the
* authorization server.
*
* This function combines two discovery steps into one call:
* 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the
* authorization server URL (RFC 9728).
* 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery).
*
* Use this when you need the authorization server metadata for operations outside the
* {@linkcode auth} orchestrator, such as token refresh or token revocation.
*
* @param serverUrl - The MCP resource server URL
* @param opts - Optional configuration
* @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint
* @param opts.fetchFn - Custom fetch function for HTTP requests
* @returns Authorization server URL, metadata, and resource metadata (if available)
*/
export async function discoverOAuthServerInfo(
serverUrl: string | URL,
opts?: {
resourceMetadataUrl?: URL;
fetchFn?: FetchLike;
}
): Promise<OAuthServerInfo> {
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl: string | undefined;

try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
serverUrl,
{ resourceMetadataUrl: opts?.resourceMetadataUrl },
opts?.fetchFn
);
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
authorizationServerUrl = resourceMetadata.authorization_servers[0];
}
} catch {
// RFC 9728 not supported -- fall back to treating the server URL as the authorization server
}

// If we don't get a valid authorization server from protected resource metadata,
// fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server
if (!authorizationServerUrl) {
authorizationServerUrl = String(new URL('/', serverUrl));
}

const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn });

return {
authorizationServerUrl,
authorizationServerMetadata,
resourceMetadata
};
}

/**
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
*/
Expand Down
Loading
Loading