diff --git a/.changeset/oauth-resource-fallback-no-prm.md b/.changeset/oauth-resource-fallback-no-prm.md new file mode 100644 index 0000000000..8a7c0dc95d --- /dev/null +++ b/.changeset/oauth-resource-fallback-no-prm.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Always send the OAuth `resource` parameter, falling back to the canonical server URI when Protected Resource Metadata (RFC 9728) is absent. Previously `selectResourceURL` returned `undefined` whenever PRM discovery failed, omitting `resource` from `/authorize` and `/token` +requests. The MCP authorization spec requires clients to send this parameter "regardless of whether authorization servers support it". diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 5f55fb7a08..4116fcb787 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -649,7 +649,7 @@ async function authInternal( if (error instanceof TypeError) { throw error; } - // RFC 9728 not available — selectResourceURL will handle undefined + // RFC 9728 not available — selectResourceURL falls back to the canonical server URI } } @@ -852,9 +852,11 @@ export async function selectResourceURL( return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource); } - // Only include resource parameter when Protected Resource Metadata is present + // Fall back to the canonical server URI when Protected Resource Metadata is absent. + // The MCP spec requires clients to send the `resource` parameter "regardless of whether + // authorization servers support it", using the canonical server URI (RFC 8707). if (!resourceMetadata) { - return undefined; + return defaultResource; } // Validate that the metadata's resource is compatible with our request diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 04d7f4a3fb..1569ee82ea 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -2713,7 +2713,7 @@ describe('OAuth Authorization', () => { expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/'); }); - it('excludes resource parameter when Protected Resource Metadata is not present', async () => { + it('falls back to the canonical server URI when Protected Resource Metadata is not present', async () => { // Mock metadata discovery where protected resource metadata is not available (404) // but authorization server metadata is available mockFetch.mockImplementation(url => { @@ -2751,7 +2751,7 @@ describe('OAuth Authorization', () => { (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); - // Call auth - should not include resource parameter + // Call auth - should still include resource parameter (canonical server URI) const result = await auth(mockProvider, { serverUrl: 'https://api.example.com/mcp-server' }); @@ -2767,11 +2767,12 @@ describe('OAuth Authorization', () => { const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]!; const authUrl: URL = redirectCall[0]; - // Resource parameter should not be present when PRM is not available - expect(authUrl.searchParams.has('resource')).toBe(false); + // Resource parameter must fall back to the canonical server URI when PRM is not available. + // The MCP spec requires clients to send `resource` regardless of authorization server support. + expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/mcp-server'); }); - it('excludes resource parameter in token exchange when Protected Resource Metadata is not present', async () => { + it('falls back to the canonical server URI in token exchange when Protected Resource Metadata is not present', async () => { // Mock metadata discovery - no protected resource metadata, but auth server metadata available mockFetch.mockImplementation(url => { const urlString = url.toString(); @@ -2830,12 +2831,12 @@ describe('OAuth Authorization', () => { expect(tokenCall).toBeDefined(); const body = tokenCall![1].body as URLSearchParams; - // Resource parameter should not be present when PRM is not available - expect(body.has('resource')).toBe(false); + // Resource parameter must fall back to the canonical server URI when PRM is not available + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); expect(body.get('code')).toBe('auth-code-123'); }); - it('excludes resource parameter in token refresh when Protected Resource Metadata is not present', async () => { + it('falls back to the canonical server URI in token refresh when Protected Resource Metadata is not present', async () => { // Mock metadata discovery - no protected resource metadata, but auth server metadata available mockFetch.mockImplementation(url => { const urlString = url.toString(); @@ -2895,8 +2896,8 @@ describe('OAuth Authorization', () => { expect(tokenCall).toBeDefined(); const body = tokenCall![1].body as URLSearchParams; - // Resource parameter should not be present when PRM is not available - expect(body.has('resource')).toBe(false); + // Resource parameter must fall back to the canonical server URI when PRM is not available + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); expect(body.get('grant_type')).toBe('refresh_token'); expect(body.get('refresh_token')).toBe('refresh123'); });