From 6df7d024fabf10c58a75fd80ad0289d0529a1a01 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Sun, 19 Jul 2026 03:13:40 -0400 Subject: [PATCH 1/2] Preserve OAuth resource indicator strings --- packages/client/src/client/auth.ts | 24 +++++++------ packages/client/test/client/auth.test.ts | 44 +++++++++++++++++++++++- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9ebc6fd251..276875536c 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -44,6 +44,8 @@ export type AddClientAuthentication = ( metadata?: AuthorizationServerMetadata ) => void | Promise; +export type OAuthResource = string | URL; + /** * Context passed to {@linkcode AuthProvider.onUnauthorized} when the server * responds with 401. Provides everything needed to refresh credentials. @@ -348,7 +350,7 @@ export interface OAuthClientProvider { * * Implementations must verify the returned resource matches the MCP server. */ - validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; + validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; /** * If implemented, provides a way for the client to invalidate (e.g. delete) the specified @@ -1185,7 +1187,7 @@ async function authInternal( await provider.saveDiscoveryState?.(freshDiscoveryState); } - const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + const resource: OAuthResource | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); // Save resource URL for providers that need it (e.g., CrossAppAccessProvider) if (resource) { @@ -1390,7 +1392,7 @@ export async function selectResourceURL( serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata -): Promise { +): Promise { const defaultResource = resourceUrlFromServerUrl(serverUrl); // If provider has custom validation, delegate to it @@ -1408,7 +1410,7 @@ export async function selectResourceURL( throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`); } // Prefer the resource from metadata since it's what the server is telling us to request - return new URL(resourceMetadata.resource); + return resourceMetadata.resource; } /** @@ -1968,7 +1970,7 @@ export async function startAuthorization( redirectUrl: string | URL; scope?: string; state?: string; - resource?: URL; + resource?: OAuthResource; } ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { let authorizationUrl: URL; @@ -2016,7 +2018,7 @@ export async function startAuthorization( } if (resource) { - authorizationUrl.searchParams.set('resource', resource.href); + authorizationUrl.searchParams.set('resource', String(resource)); } return { authorizationUrl, codeVerifier }; @@ -2064,7 +2066,7 @@ export async function executeTokenRequest( tokenRequestParams: URLSearchParams; clientInformation?: OAuthClientInformationMixed; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; - resource?: URL; + resource?: OAuthResource; fetchFn?: FetchLike; } ): Promise { @@ -2076,7 +2078,7 @@ export async function executeTokenRequest( }); if (resource) { - tokenRequestParams.set('resource', resource.href); + tokenRequestParams.set('resource', String(resource)); } if (addClientAuthentication) { @@ -2147,7 +2149,7 @@ export async function exchangeAuthorization( iss?: string; codeVerifier: string; redirectUri: string | URL; - resource?: URL; + resource?: OAuthResource; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -2195,7 +2197,7 @@ export async function refreshAuthorization( metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; refreshToken: string; - resource?: URL; + resource?: OAuthResource; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -2257,7 +2259,7 @@ export async function fetchToken( fetchFn }: { metadata?: AuthorizationServerMetadata; - resource?: URL; + resource?: OAuthResource; /** Authorization code for the default `authorization_code` grant flow */ authorizationCode?: string; /** diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 62c6faed9a..315e1ddbc8 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -35,6 +35,7 @@ import { resolveAuthorizationCallbackParams, resolveClientMetadata, selectClientAuthMethod, + selectResourceURL, startAuthorization, UnauthorizedError, validateAuthorizationResponseIssuer, @@ -1580,7 +1581,7 @@ describe('OAuth Authorization', () => { 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/'); + expect(body.get('resource')).toBe('https://resource.example.com'); }); it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { @@ -1743,6 +1744,17 @@ describe('OAuth Authorization', () => { }); }); + describe('selectResourceURL', () => { + it('preserves the exact protected-resource metadata resource string', async () => { + const resource = await selectResourceURL('https://resource.example.com/mcp', {} as OAuthClientProvider, { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }); + + expect(resource).toBe('https://resource.example.com'); + }); + }); + describe('startAuthorization', () => { const validMetadata = { issuer: 'https://auth.example.com', @@ -1787,6 +1799,17 @@ describe('OAuth Authorization', () => { expect(codeVerifier).toBe('test_verifier'); }); + it('preserves string resource indicators in the authorization URL', async () => { + const { authorizationUrl } = await startAuthorization('https://auth.example.com', { + metadata: undefined, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + resource: 'https://api.example.com' + }); + + expect(authorizationUrl.searchParams.get('resource')).toBe('https://api.example.com'); + }); + it('includes scope parameter when provided', async () => { const { authorizationUrl } = await startAuthorization('https://auth.example.com', { clientInformation: validClientInfo, @@ -1965,6 +1988,25 @@ describe('OAuth Authorization', () => { expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); + it('preserves string resource indicators in the token request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + await exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + resource: 'https://api.example.com' + }); + + const body = mockFetch.mock.calls[0]![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://api.example.com'); + }); + it('allows for string "expires_in" values', async () => { mockFetch.mockResolvedValueOnce({ ok: true, From 574649c521650c334982abfe8f70e6a8805c4e20 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Sun, 19 Jul 2026 03:24:39 -0400 Subject: [PATCH 2/2] Add changeset for OAuth resource indicator fix --- .changeset/preserve-oauth-resource-indicator-strings.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/preserve-oauth-resource-indicator-strings.md diff --git a/.changeset/preserve-oauth-resource-indicator-strings.md b/.changeset/preserve-oauth-resource-indicator-strings.md new file mode 100644 index 0000000000..f4c288d73c --- /dev/null +++ b/.changeset/preserve-oauth-resource-indicator-strings.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Preserve OAuth resource indicator strings from protected resource metadata +instead of normalizing them through `URL`.