From c92db323b6e538f2b9d02fc00cdbb79989430ca9 Mon Sep 17 00:00:00 2001 From: Genmin Date: Wed, 29 Apr 2026 22:31:47 -0700 Subject: [PATCH 1/4] fix(client): preserve OAuth resource metadata indicator --- packages/client/src/client/auth.ts | 32 ++++++++++------- packages/client/test/client/auth.test.ts | 45 +++++++++++++++++++++++- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 5f55fb7a0..ff4683275 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -236,7 +236,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 @@ -384,6 +384,11 @@ function isClientAuthMethod(method: string): method is ClientAuthMethod { const AUTHORIZATION_CODE_RESPONSE_TYPE = 'code'; const AUTHORIZATION_CODE_CHALLENGE_METHOD = 'S256'; +type OAuthResourceIndicator = string | URL; + +function serializeResourceIndicator(resource: OAuthResourceIndicator): string { + return typeof resource === 'string' ? resource : resource.href; +} /** * Determines the best client authentication method to use based on server support and client configuration. @@ -684,11 +689,11 @@ async function authInternal( // Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider) await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl)); - const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + const resource: OAuthResourceIndicator | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); // Save resource URL for providers that need it (e.g., CrossAppAccessProvider) if (resource) { - await provider.saveResourceUrl?.(String(resource)); + await provider.saveResourceUrl?.(serializeResourceIndicator(resource)); } // Scope selection used consistently for DCR and the authorization request. @@ -844,7 +849,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 @@ -861,8 +866,9 @@ export async function selectResourceURL( if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) { 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); + // Prefer the exact resource indicator from metadata since it's what the server is telling us to request. + // Constructing a URL would normalize pathless origins by appending "/", which can change the audience. + return resourceMetadata.resource; } /** @@ -1376,7 +1382,7 @@ export async function startAuthorization( redirectUrl: string | URL; scope?: string; state?: string; - resource?: URL; + resource?: OAuthResourceIndicator; } ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { let authorizationUrl: URL; @@ -1424,7 +1430,7 @@ export async function startAuthorization( } if (resource) { - authorizationUrl.searchParams.set('resource', resource.href); + authorizationUrl.searchParams.set('resource', serializeResourceIndicator(resource)); } return { authorizationUrl, codeVerifier }; @@ -1472,7 +1478,7 @@ export async function executeTokenRequest( tokenRequestParams: URLSearchParams; clientInformation?: OAuthClientInformationMixed; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; - resource?: URL; + resource?: OAuthResourceIndicator; fetchFn?: FetchLike; } ): Promise { @@ -1484,7 +1490,7 @@ export async function executeTokenRequest( }); if (resource) { - tokenRequestParams.set('resource', resource.href); + tokenRequestParams.set('resource', serializeResourceIndicator(resource)); } if (addClientAuthentication) { @@ -1548,7 +1554,7 @@ export async function exchangeAuthorization( authorizationCode: string; codeVerifier: string; redirectUri: string | URL; - resource?: URL; + resource?: OAuthResourceIndicator; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -1590,7 +1596,7 @@ export async function refreshAuthorization( metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; refreshToken: string; - resource?: URL; + resource?: OAuthResourceIndicator; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -1651,7 +1657,7 @@ export async function fetchToken( fetchFn }: { metadata?: AuthorizationServerMetadata; - resource?: URL; + resource?: OAuthResourceIndicator; /** Authorization code for the default `authorization_code` grant flow */ authorizationCode?: string; /** Optional scope parameter from auth() options */ diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 04d7f4a3f..5f27e7941 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -18,6 +18,7 @@ import { refreshAuthorization, registerClient, selectClientAuthMethod, + selectResourceURL, startAuthorization, validateClientMetadataUrl } from '../../src/client/auth.js'; @@ -1301,7 +1302,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 () => { @@ -1464,6 +1465,17 @@ describe('OAuth Authorization', () => { }); }); + describe('selectResourceURL', () => { + it('preserves the exact protected resource metadata resource indicator', async () => { + const resource = await selectResourceURL('https://api.example.com/mcp-server', {} as OAuthClientProvider, { + resource: 'https://api.example.com', + authorization_servers: ['https://auth.example.com'] + }); + + expect(resource).toBe('https://api.example.com'); + }); + }); + describe('startAuthorization', () => { const validMetadata = { issuer: 'https://auth.example.com', @@ -1508,6 +1520,17 @@ describe('OAuth Authorization', () => { expect(codeVerifier).toBe('test_verifier'); }); + it('preserves string resource indicators exactly', 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, @@ -1686,6 +1709,26 @@ describe('OAuth Authorization', () => { expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); + it('preserves string resource indicators exactly in token requests', 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 options = mockFetch.mock.calls[0]![1]; + const body = options.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 13e20650763ef9be9cad2d7c4045a64ebfa2b4e4 Mon Sep 17 00:00:00 2001 From: Genmin Date: Thu, 30 Apr 2026 07:29:46 -0700 Subject: [PATCH 2/4] fix: export oauth resource indicator type --- .changeset/fix-oauth-resource-indicator-preserve-metadata.md | 5 +++++ packages/client/src/client/auth.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-oauth-resource-indicator-preserve-metadata.md diff --git a/.changeset/fix-oauth-resource-indicator-preserve-metadata.md b/.changeset/fix-oauth-resource-indicator-preserve-metadata.md new file mode 100644 index 000000000..d1142486e --- /dev/null +++ b/.changeset/fix-oauth-resource-indicator-preserve-metadata.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/client": patch +--- + +fix(client): preserve OAuth resource metadata indicator diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index ff4683275..c27da70ef 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -384,7 +384,7 @@ function isClientAuthMethod(method: string): method is ClientAuthMethod { const AUTHORIZATION_CODE_RESPONSE_TYPE = 'code'; const AUTHORIZATION_CODE_CHALLENGE_METHOD = 'S256'; -type OAuthResourceIndicator = string | URL; +export type OAuthResourceIndicator = string | URL; function serializeResourceIndicator(resource: OAuthResourceIndicator): string { return typeof resource === 'string' ? resource : resource.href; From 425d0405b21f70541f7cfd7b240552ca11ddab85 Mon Sep 17 00:00:00 2001 From: Genmin Date: Thu, 30 Apr 2026 10:04:20 -0700 Subject: [PATCH 3/4] chore: retry transient CI From 41dd3a13a1fe64a65ef80fba6b7429d2d9d1f08e Mon Sep 17 00:00:00 2001 From: Genmin Date: Thu, 30 Apr 2026 10:23:00 -0700 Subject: [PATCH 4/4] chore: retry transient Cloudflare worker CI