From 7ebd854e4ccd4dca634f5a740e39ffe04e9f7b8d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 12 Feb 2026 16:46:05 +0000 Subject: [PATCH 1/8] feat: expose discoverOAuthServerInfo() and add provider caching for auth server URL --- .changeset/expose-auth-server-discovery.md | 9 + packages/client/src/client/auth.ts | 136 ++++++++-- packages/client/test/client/auth.test.ts | 292 +++++++++++++++++++++ 3 files changed, 417 insertions(+), 20 deletions(-) create mode 100644 .changeset/expose-auth-server-discovery.md diff --git a/.changeset/expose-auth-server-discovery.md b/.changeset/expose-auth-server-discovery.md new file mode 100644 index 000000000..3c9a5ce23 --- /dev/null +++ b/.changeset/expose-auth-server-discovery.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add `discoverOAuthServerInfo()` function and optional provider caching for authorization server discovery + +- 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 optional `OAuthClientProvider` methods `saveAuthorizationServerUrl()` and `authorizationServerUrl()` allow providers to persist the discovered authorization server URL across sessions. When `authorizationServerUrl()` returns a cached URL, `auth()` skips RFC 9728 discovery, reducing latency on subsequent calls. +- New `OAuthServerInfo` type exported for the return value of `discoverOAuthServerInfo()`. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 03b38b327..29746f383 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -183,6 +183,23 @@ export interface OAuthClientProvider { * } */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + + /** + * Saves the authorization server URL discovered during the OAuth flow. + * + * Called by {@linkcode auth} after RFC 9728 discovery so providers can persist the + * URL for later use in operations like token refresh or revocation, avoiding + * redundant discovery requests. + */ + saveAuthorizationServerUrl?(url: string | URL): void | Promise; + + /** + * Returns a previously saved authorization server URL, or `undefined` if none is cached. + * + * When available, {@linkcode auth} skips RFC 9728 protected resource metadata + * discovery and uses this URL directly, reducing latency on subsequent calls. + */ + authorizationServerUrl?(): string | URL | undefined | Promise; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -395,32 +412,30 @@ async function authInternal( fetchFn?: FetchLike; } ): Promise { - let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - let authorizationServerUrl: string | URL | undefined; + // Check if the provider has a cached authorization server URL to skip discovery + const cachedAuthServerUrl = await provider.authorizationServerUrl?.(); - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); - if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { - authorizationServerUrl = resourceMetadata.authorization_servers[0]; - } - } catch { - // Ignore errors and fall back to /.well-known/oauth-authorization-server - } + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | URL; + let metadata: AuthorizationServerMetadata | undefined; - /** - * 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); + if (cachedAuthServerUrl) { + // Use cached URL, skip RFC 9728 discovery + authorizationServerUrl = cachedAuthServerUrl; + metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn }); + } else { + // Full discovery via RFC 9728 + const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl, fetchFn }); + authorizationServerUrl = serverInfo.authorizationServerUrl; + metadata = serverInfo.authorizationServerMetadata; + resourceMetadata = serverInfo.resourceMetadata; + + // Persist the discovered URL for future use + await provider.saveAuthorizationServerUrl?.(authorizationServerUrl); } 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) { @@ -941,6 +956,87 @@ export async function discoverAuthorizationServerMetadata( return undefined; } +/** + * Result of {@linkcode discoverOAuthServerInfo}. + */ +export interface OAuthServerInfo { + /** + * The authorization server URL, either discovered via RFC 9728 + * or derived from the MCP server URL as a fallback. + */ + authorizationServerUrl: string | URL; + + /** + * 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 { + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | URL | 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 = 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. */ diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 4bbd08859..ef02e15f0 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -10,6 +10,7 @@ import { discoverAuthorizationServerMetadata, discoverOAuthMetadata, discoverOAuthProtectedResourceMetadata, + discoverOAuthServerInfo, exchangeAuthorization, extractWWWAuthenticateParams, isHttpsUrl, @@ -895,6 +896,297 @@ describe('OAuth Authorization', () => { }); }); + describe('discoverOAuthServerInfo', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + it('returns auth server from RFC 9728 protected resource metadata', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://auth.example.com'); + expect(result.resourceMetadata).toEqual(validResourceMetadata); + expect(result.authorizationServerMetadata).toEqual(validAuthMetadata); + }); + + it('falls back to server URL when RFC 9728 is not supported', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // RFC 9728 returns 404 + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + ...validAuthMetadata, + issuer: 'https://resource.example.com' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + // Should fall back to server URL origin + expect(result.authorizationServerUrl).toEqual(new URL('/', 'https://resource.example.com')); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toBeDefined(); + }); + + it('returns undefined metadata when auth server metadata discovery fails', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + // All auth server metadata endpoints return 404 + return Promise.resolve({ + ok: false, + status: 404, + text: async () => 'Not found' + }); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://auth.example.com'); + expect(result.resourceMetadata).toEqual(validResourceMetadata); + expect(result.authorizationServerMetadata).toBeUndefined(); + }); + + it('passes custom fetchFn through to discovery functions', async () => { + const customFetch = vi.fn().mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await discoverOAuthServerInfo('https://resource.example.com', { + fetchFn: customFetch as unknown as typeof fetch + }); + + // Verify the custom fetch was used for both discovery calls + expect(customFetch).toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('auth with provider authorization server URL caching', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + function createMockProvider(overrides: Partial = {}): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'test-client-id', + client_secret: 'test-client-secret' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + ...overrides + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls saveAuthorizationServerUrl after discovery when provider implements it', async () => { + const saveAuthorizationServerUrl = vi.fn(); + const provider = createMockProvider({ saveAuthorizationServerUrl }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(saveAuthorizationServerUrl).toHaveBeenCalledWith('https://auth.example.com'); + }); + + it('skips RFC 9728 discovery when provider returns cached authorization server URL', async () => { + const provider = createMockProvider({ + authorizationServerUrl: vi.fn().mockResolvedValue('https://auth.example.com'), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + // Should NOT have called the protected resource metadata endpoint + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); + expect(prmCalls).toHaveLength(0); + }); + + it('works normally when provider does not implement caching methods', async () => { + const provider = createMockProvider(); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + // Should not throw when caching methods are not implemented + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + // Should have done full discovery (called PRM endpoint) + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); + expect(prmCalls.length).toBeGreaterThan(0); + }); + }); + describe('selectClientAuthMethod', () => { it('selects the correct client authentication method from client information', () => { const clientInfo = { From 54cb233de819294f86800fec1f8409576ee1ea05 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 12 Feb 2026 17:11:39 +0000 Subject: [PATCH 2/8] fix: fetch resource metadata on cached path to preserve resource parameter --- packages/client/src/client/auth.ts | 9 +++++++- packages/client/test/client/auth.test.ts | 27 +++++++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 29746f383..d553a9310 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -420,9 +420,16 @@ async function authInternal( let metadata: AuthorizationServerMetadata | undefined; if (cachedAuthServerUrl) { - // Use cached URL, skip RFC 9728 discovery + // Use cached URL, skip auth server URL derivation from RFC 9728 authorizationServerUrl = cachedAuthServerUrl; metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn }); + + // Still fetch resource metadata for selectResourceURL (needed for the resource parameter) + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); + } catch { + // RFC 9728 not available — selectResourceURL will handle undefined + } } else { // Full discovery via RFC 9728 const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl, fetchFn }); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index ef02e15f0..4f74b68e5 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1106,7 +1106,7 @@ describe('OAuth Authorization', () => { expect(saveAuthorizationServerUrl).toHaveBeenCalledWith('https://auth.example.com'); }); - it('skips RFC 9728 discovery when provider returns cached authorization server URL', async () => { + it('uses cached auth server URL but still fetches resource metadata for resource parameter', async () => { const provider = createMockProvider({ authorizationServerUrl: vi.fn().mockResolvedValue('https://auth.example.com'), tokens: vi.fn().mockResolvedValue({ @@ -1119,6 +1119,14 @@ describe('OAuth Authorization', () => { mockFetch.mockImplementation(url => { const urlString = url.toString(); + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + if (urlString.includes('/.well-known/oauth-authorization-server')) { return Promise.resolve({ ok: true, @@ -1148,9 +1156,22 @@ describe('OAuth Authorization', () => { }); expect(result).toBe('AUTHORIZED'); - // Should NOT have called the protected resource metadata endpoint + + // Should use cached auth server URL for auth server metadata discovery + const asMdCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-authorization-server')); + expect(asMdCalls.length).toBeGreaterThan(0); + // The auth server metadata call should target the cached URL, not the resource server + expect(asMdCalls[0]![0].toString()).toContain('auth.example.com'); + + // Should still fetch resource metadata for the resource parameter const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); - expect(prmCalls).toHaveLength(0); + expect(prmCalls.length).toBeGreaterThan(0); + + // Verify the token request includes the resource parameter + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://resource.example.com/'); }); it('works normally when provider does not implement caching methods', async () => { From 1437f1d38618d27531888229417b1140084c5314 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 12 Feb 2026 17:31:53 +0000 Subject: [PATCH 3/8] fix: add cache invalidation JSDoc guidance and test for resourceMetadataUrl override --- packages/client/src/client/auth.ts | 3 +++ packages/client/test/client/auth.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index d553a9310..57b559e45 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -198,6 +198,9 @@ export interface OAuthClientProvider { * * When available, {@linkcode auth} skips RFC 9728 protected resource metadata * discovery and uses this URL directly, reducing latency on subsequent calls. + * + * Providers should clear the cached URL on repeated authentication failures + * to allow re-discovery in case the authorization server has changed. */ authorizationServerUrl?(): string | URL | undefined | Promise; } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 4f74b68e5..a92918611 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1031,6 +1031,40 @@ describe('OAuth Authorization', () => { expect(customFetch).toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled(); }); + + it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { + const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === overrideUrl.toString()) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com', { + resourceMetadataUrl: overrideUrl + }); + + expect(result.resourceMetadata).toEqual(validResourceMetadata); + // Verify the override URL was used instead of the default well-known path + expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString()); + }); }); describe('auth with provider authorization server URL caching', () => { From cb5cb536787378df73acbde60b3fe5fd6300ac56 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 13 Feb 2026 13:29:04 +0000 Subject: [PATCH 4/8] refactor: replace narrow auth URL caching with unified OAuthDiscoveryState Address PR review feedback from pcarleton and ochafik: - Replace saveAuthorizationServerUrl/authorizationServerUrl with unified saveDiscoveryState/discoveryState that stores all discovery results - Add OAuthDiscoveryState type covering auth server URL, resource metadata URL, resource metadata, and auth server metadata - Add 'discovery' scope to invalidateCredentials for cache clearing - Subsumes the use case from PR #1350 (persisting resourceMetadataUrl across browser redirects) - Cached path now restores full discovery state including resource metadata, fixing the resource parameter regression Co-authored-by: hassan123789 <49031989+hassan123789@users.noreply.github.com> --- .changeset/expose-auth-server-discovery.md | 5 +- packages/client/src/client/auth.ts | 97 ++++++++++++++++------ packages/client/test/client/auth.test.ts | 54 +++++------- 3 files changed, 95 insertions(+), 61 deletions(-) diff --git a/.changeset/expose-auth-server-discovery.md b/.changeset/expose-auth-server-discovery.md index 3c9a5ce23..443dce893 100644 --- a/.changeset/expose-auth-server-discovery.md +++ b/.changeset/expose-auth-server-discovery.md @@ -2,8 +2,9 @@ '@modelcontextprotocol/client': minor --- -Add `discoverOAuthServerInfo()` function and optional provider caching for authorization server discovery +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 optional `OAuthClientProvider` methods `saveAuthorizationServerUrl()` and `authorizationServerUrl()` allow providers to persist the discovered authorization server URL across sessions. When `authorizationServerUrl()` returns a cached URL, `auth()` skips RFC 9728 discovery, reducing latency on subsequent calls. +- 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()`. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 57b559e45..fd8c70b3b 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -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; + invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise; /** * Prepares grant-specific parameters for a token request. @@ -185,24 +185,47 @@ export interface OAuthClientProvider { prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; /** - * Saves the authorization server URL discovered during the OAuth flow. + * 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. * - * Called by {@linkcode auth} after RFC 9728 discovery so providers can persist the - * URL for later use in operations like token refresh or revocation, avoiding - * redundant discovery requests. + * 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. */ - saveAuthorizationServerUrl?(url: string | URL): void | Promise; + saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; /** - * Returns a previously saved authorization server URL, or `undefined` if none is cached. + * Returns previously saved discovery state, or `undefined` if none is cached. * - * When available, {@linkcode auth} skips RFC 9728 protected resource metadata - * discovery and uses this URL directly, reducing latency on subsequent calls. + * 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 the cached URL on repeated authentication failures - * to allow re-discovery in case the authorization server has changed. + * 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. */ - authorizationServerUrl?(): string | URL | undefined | Promise; + discoveryState?(): OAuthDiscoveryState | undefined | Promise; +} + +/** + * 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. + */ +export interface OAuthDiscoveryState { + /** The authorization server URL discovered via RFC 9728 or derived from the server URL. */ + authorizationServerUrl: string; + /** The URL at which the protected resource metadata was found, if available. */ + resourceMetadataUrl?: string; + /** The OAuth 2.0 Protected Resource Metadata from RFC 9728, if available. */ + resourceMetadata?: OAuthProtectedResourceMetadata; + /** The authorization server metadata (endpoints, capabilities), if available. */ + authorizationServerMetadata?: AuthorizationServerMetadata; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -415,33 +438,53 @@ async function authInternal( fetchFn?: FetchLike; } ): Promise { - // Check if the provider has a cached authorization server URL to skip discovery - const cachedAuthServerUrl = await provider.authorizationServerUrl?.(); + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | URL; let metadata: AuthorizationServerMetadata | undefined; - if (cachedAuthServerUrl) { - // Use cached URL, skip auth server URL derivation from RFC 9728 - authorizationServerUrl = cachedAuthServerUrl; - metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn }); - - // Still fetch resource metadata for selectResourceURL (needed for the resource parameter) - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); - } catch { - // RFC 9728 not available — selectResourceURL will handle 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); + } + + 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 + } } } else { // Full discovery via RFC 9728 - const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl, fetchFn }); + const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); authorizationServerUrl = serverInfo.authorizationServerUrl; metadata = serverInfo.authorizationServerMetadata; resourceMetadata = serverInfo.resourceMetadata; - // Persist the discovered URL for future use - await provider.saveAuthorizationServerUrl?.(authorizationServerUrl); + // Persist discovery state for future use + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); } const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index a92918611..8e6d49e47 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1109,9 +1109,9 @@ describe('OAuth Authorization', () => { vi.clearAllMocks(); }); - it('calls saveAuthorizationServerUrl after discovery when provider implements it', async () => { - const saveAuthorizationServerUrl = vi.fn(); - const provider = createMockProvider({ saveAuthorizationServerUrl }); + it('calls saveDiscoveryState after discovery when provider implements it', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ saveDiscoveryState }); mockFetch.mockImplementation(url => { const urlString = url.toString(); @@ -1137,12 +1137,22 @@ describe('OAuth Authorization', () => { await auth(provider, { serverUrl: 'https://resource.example.com' }); - expect(saveAuthorizationServerUrl).toHaveBeenCalledWith('https://auth.example.com'); + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }) + ); }); - it('uses cached auth server URL but still fetches resource metadata for resource parameter', async () => { + it('restores full discovery state from cache including resource metadata', async () => { const provider = createMockProvider({ - authorizationServerUrl: vi.fn().mockResolvedValue('https://auth.example.com'), + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }), tokens: vi.fn().mockResolvedValue({ access_token: 'valid-token', refresh_token: 'refresh-token', @@ -1153,22 +1163,6 @@ describe('OAuth Authorization', () => { mockFetch.mockImplementation(url => { const urlString = url.toString(); - if (urlString.includes('/.well-known/oauth-protected-resource')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => validResourceMetadata - }); - } - - if (urlString.includes('/.well-known/oauth-authorization-server')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => validAuthMetadata - }); - } - if (urlString.includes('/token')) { return Promise.resolve({ ok: true, @@ -1191,17 +1185,13 @@ describe('OAuth Authorization', () => { expect(result).toBe('AUTHORIZED'); - // Should use cached auth server URL for auth server metadata discovery - const asMdCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-authorization-server')); - expect(asMdCalls.length).toBeGreaterThan(0); - // The auth server metadata call should target the cached URL, not the resource server - expect(asMdCalls[0]![0].toString()).toContain('auth.example.com'); - - // Should still fetch resource metadata for the resource parameter - const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); - expect(prmCalls.length).toBeGreaterThan(0); + // Should NOT have called any discovery endpoints -- all from cache + const discoveryCalls = mockFetch.mock.calls.filter( + call => call[0].toString().includes('oauth-protected-resource') || call[0].toString().includes('oauth-authorization-server') + ); + expect(discoveryCalls).toHaveLength(0); - // Verify the token request includes the resource parameter + // Verify the token request includes the resource parameter from cached metadata const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); expect(tokenCall).toBeDefined(); const body = tokenCall![1].body as URLSearchParams; From d6a75e46da50730031181dcce19c99b8224717bd Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 13 Feb 2026 13:57:12 +0000 Subject: [PATCH 5/8] fix: re-save enriched discovery state when partial cache is supplemented --- packages/client/src/client/auth.ts | 10 ++++ packages/client/test/client/auth.test.ts | 62 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index fd8c70b3b..89f97e63e 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -471,6 +471,16 @@ async function authInternal( // RFC 9728 not available — selectResourceURL will handle undefined } } + + // 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 }); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 8e6d49e47..0e3f6414e 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1198,6 +1198,68 @@ describe('OAuth Authorization', () => { expect(body.get('resource')).toBe('https://resource.example.com/'); }); + it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ + // Partial cache: auth server URL only, no metadata + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com' + }), + saveDiscoveryState, + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + // Should re-save with the enriched state including fetched metadata + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + authorizationServerMetadata: validAuthMetadata, + resourceMetadata: validResourceMetadata + }) + ); + }); + it('works normally when provider does not implement caching methods', async () => { const provider = createMockProvider(); From f5c980e875df6df3c5d471a67fa5dfae0551370f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 13 Feb 2026 14:08:08 +0000 Subject: [PATCH 6/8] refactor: normalize OAuthServerInfo.authorizationServerUrl to string --- packages/client/src/client/auth.ts | 6 +++--- packages/client/test/client/auth.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 89f97e63e..d0d31ddf8 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1027,7 +1027,7 @@ export interface OAuthServerInfo { * The authorization server URL, either discovered via RFC 9728 * or derived from the MCP server URL as a fallback. */ - authorizationServerUrl: string | URL; + authorizationServerUrl: string; /** * The authorization server metadata (endpoints, capabilities), @@ -1070,7 +1070,7 @@ export async function discoverOAuthServerInfo( } ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - let authorizationServerUrl: string | URL | undefined; + let authorizationServerUrl: string | undefined; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata( @@ -1088,7 +1088,7 @@ export async function discoverOAuthServerInfo( // 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 = new URL('/', serverUrl); + authorizationServerUrl = String(new URL('/', serverUrl)); } const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn }); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 0e3f6414e..dcd65e4bb 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -968,7 +968,7 @@ describe('OAuth Authorization', () => { const result = await discoverOAuthServerInfo('https://resource.example.com'); // Should fall back to server URL origin - expect(result.authorizationServerUrl).toEqual(new URL('/', 'https://resource.example.com')); + expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); expect(result.resourceMetadata).toBeUndefined(); expect(result.authorizationServerMetadata).toBeDefined(); }); From d2cd62daa89e4af66569ec0bedf6cca2f5590ca1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 13 Feb 2026 14:15:19 +0000 Subject: [PATCH 7/8] test: trim redundant tests, add resourceMetadataUrl-from-cache coverage --- packages/client/test/client/auth.test.ts | 101 ++++++++--------------- 1 file changed, 33 insertions(+), 68 deletions(-) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index dcd65e4bb..742dbc143 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -973,65 +973,6 @@ describe('OAuth Authorization', () => { expect(result.authorizationServerMetadata).toBeDefined(); }); - it('returns undefined metadata when auth server metadata discovery fails', async () => { - mockFetch.mockImplementation(url => { - const urlString = url.toString(); - - if (urlString.includes('/.well-known/oauth-protected-resource')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => validResourceMetadata - }); - } - - // All auth server metadata endpoints return 404 - return Promise.resolve({ - ok: false, - status: 404, - text: async () => 'Not found' - }); - }); - - const result = await discoverOAuthServerInfo('https://resource.example.com'); - - expect(result.authorizationServerUrl).toBe('https://auth.example.com'); - expect(result.resourceMetadata).toEqual(validResourceMetadata); - expect(result.authorizationServerMetadata).toBeUndefined(); - }); - - it('passes custom fetchFn through to discovery functions', async () => { - const customFetch = vi.fn().mockImplementation(url => { - const urlString = url.toString(); - - if (urlString.includes('/.well-known/oauth-protected-resource')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => validResourceMetadata - }); - } - - if (urlString.includes('/.well-known/oauth-authorization-server')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => validAuthMetadata - }); - } - - return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); - }); - - await discoverOAuthServerInfo('https://resource.example.com', { - fetchFn: customFetch as unknown as typeof fetch - }); - - // Verify the custom fetch was used for both discovery calls - expect(customFetch).toHaveBeenCalled(); - expect(mockFetch).not.toHaveBeenCalled(); - }); - it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); @@ -1260,13 +1201,28 @@ describe('OAuth Authorization', () => { ); }); - it('works normally when provider does not implement caching methods', async () => { - const provider = createMockProvider(); + it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => { + const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource'; + const provider = createMockProvider({ + // Cache has auth server URL + resourceMetadataUrl but no resourceMetadata + // (simulates browser redirect where PRM URL was saved but metadata wasn't) + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadataUrl: cachedPrmUrl, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); mockFetch.mockImplementation(url => { const urlString = url.toString(); - if (urlString.includes('/.well-known/oauth-protected-resource')) { + // The cached PRM URL should be used for resource metadata discovery + if (urlString === cachedPrmUrl) { return Promise.resolve({ ok: true, status: 200, @@ -1274,23 +1230,32 @@ describe('OAuth Authorization', () => { }); } - if (urlString.includes('/.well-known/oauth-authorization-server')) { + if (urlString.includes('/token')) { return Promise.resolve({ ok: true, status: 200, - json: async () => validAuthMetadata + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) }); } return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); }); - // Should not throw when caching methods are not implemented - await auth(provider, { serverUrl: 'https://resource.example.com' }); + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); - // Should have done full discovery (called PRM endpoint) + // Should have used the cached PRM URL, not the default well-known path const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); - expect(prmCalls.length).toBeGreaterThan(0); + expect(prmCalls).toHaveLength(1); + expect(prmCalls[0]![0].toString()).toBe(cachedPrmUrl); }); }); From 864c44a5e5f98083cfdc512a9a343dd7a0db438a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 13 Feb 2026 17:49:35 +0000 Subject: [PATCH 8/8] refactor: make OAuthDiscoveryState extend OAuthServerInfo, add TODOs Address PR feedback: - OAuthDiscoveryState now extends OAuthServerInfo instead of redeclaring the same fields, keeping them linked while allowing divergence for cache-specific fields like resourceMetadataUrl. - Add TODO for potential authorizationServerMetadataUrl field to capture the exact well-known URL where AS metadata was discovered. - Add TODO noting resourceMetadataUrl is only populated when explicitly provided, not when derived internally. --- packages/client/src/client/auth.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index d0d31ddf8..6d0207469 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -217,15 +217,12 @@ export interface OAuthClientProvider { * authorization server metadata discovery. Persisting this state avoids * redundant discovery HTTP requests on subsequent {@linkcode auth} calls. */ -export interface OAuthDiscoveryState { - /** The authorization server URL discovered via RFC 9728 or derived from the server URL. */ - authorizationServerUrl: string; +// 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; - /** The OAuth 2.0 Protected Resource Metadata from RFC 9728, if available. */ - resourceMetadata?: OAuthProtectedResourceMetadata; - /** The authorization server metadata (endpoints, capabilities), if available. */ - authorizationServerMetadata?: AuthorizationServerMetadata; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -489,6 +486,9 @@ async function authInternal( 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(),