@@ -7,6 +7,7 @@ import type { OAuthClientProvider } from '../../src/client/auth.js';
77import {
88 auth ,
99 buildDiscoveryUrls ,
10+ determineScope ,
1011 discoverAuthorizationServerMetadata ,
1112 discoverOAuthMetadata ,
1213 discoverOAuthProtectedResourceMetadata ,
@@ -3734,4 +3735,227 @@ describe('OAuth Authorization', () => {
37343735 } ) ;
37353736 } ) ;
37363737 } ) ;
3738+
3739+ describe ( 'determineScope' , ( ) => {
3740+ const baseClientMetadata = {
3741+ redirect_uris : [ 'http://localhost:3000/callback' ] ,
3742+ client_name : 'Test Client'
3743+ } ;
3744+
3745+ describe ( 'MCP Scope Selection Strategy' , ( ) => {
3746+ it ( 'returns explicit requestedScope as-is (priority 1)' , ( ) => {
3747+ const result = determineScope ( {
3748+ requestedScope : 'files:read' ,
3749+ resourceMetadata : {
3750+ resource : 'https://api.example.com/' ,
3751+ scopes_supported : [ 'mcp:read' , 'mcp:write' ]
3752+ } ,
3753+ clientMetadata : {
3754+ ...baseClientMetadata ,
3755+ scope : 'fallback:scope'
3756+ }
3757+ } ) ;
3758+
3759+ expect ( result ) . toBe ( 'files:read' ) ;
3760+ } ) ;
3761+
3762+ it ( 'uses PRM scopes_supported when no explicit scope (priority 2)' , ( ) => {
3763+ const result = determineScope ( {
3764+ resourceMetadata : {
3765+ resource : 'https://api.example.com/' ,
3766+ scopes_supported : [ 'mcp:read' , 'mcp:write' , 'mcp:admin' ]
3767+ } ,
3768+ clientMetadata : {
3769+ ...baseClientMetadata ,
3770+ scope : 'fallback:scope'
3771+ }
3772+ } ) ;
3773+
3774+ expect ( result ) . toBe ( 'mcp:read mcp:write mcp:admin' ) ;
3775+ } ) ;
3776+
3777+ it ( 'falls back to clientMetadata.scope when no PRM scopes (priority 3)' , ( ) => {
3778+ const result = determineScope ( {
3779+ resourceMetadata : {
3780+ resource : 'https://api.example.com/'
3781+ } ,
3782+ clientMetadata : {
3783+ ...baseClientMetadata ,
3784+ scope : 'client:default'
3785+ }
3786+ } ) ;
3787+
3788+ expect ( result ) . toBe ( 'client:default' ) ;
3789+ } ) ;
3790+
3791+ it ( 'returns undefined when no scope source available (priority 4)' , ( ) => {
3792+ const result = determineScope ( {
3793+ clientMetadata : baseClientMetadata
3794+ } ) ;
3795+
3796+ expect ( result ) . toBeUndefined ( ) ;
3797+ } ) ;
3798+
3799+ it ( 'returns undefined when PRM has no scopes_supported and clientMetadata has no scope' , ( ) => {
3800+ const result = determineScope ( {
3801+ resourceMetadata : {
3802+ resource : 'https://api.example.com/'
3803+ } ,
3804+ clientMetadata : baseClientMetadata
3805+ } ) ;
3806+
3807+ expect ( result ) . toBeUndefined ( ) ;
3808+ } ) ;
3809+ } ) ;
3810+
3811+ describe ( 'SEP-2207: offline_access scope augmentation' , ( ) => {
3812+ const asMetadataWithOfflineAccess = {
3813+ issuer : 'https://auth.example.com' ,
3814+ authorization_endpoint : 'https://auth.example.com/authorize' ,
3815+ token_endpoint : 'https://auth.example.com/token' ,
3816+ response_types_supported : [ 'code' ] as string [ ] ,
3817+ scopes_supported : [ 'openid' , 'profile' , 'offline_access' ]
3818+ } ;
3819+
3820+ const asMetadataWithoutOfflineAccess = {
3821+ issuer : 'https://auth.example.com' ,
3822+ authorization_endpoint : 'https://auth.example.com/authorize' ,
3823+ token_endpoint : 'https://auth.example.com/token' ,
3824+ response_types_supported : [ 'code' ] as string [ ] ,
3825+ scopes_supported : [ 'openid' , 'profile' ]
3826+ } ;
3827+
3828+ const clientMetadataWithRefreshToken = {
3829+ ...baseClientMetadata ,
3830+ grant_types : [ 'authorization_code' , 'refresh_token' ]
3831+ } ;
3832+
3833+ it ( 'augments explicit scope with offline_access' , ( ) => {
3834+ const result = determineScope ( {
3835+ requestedScope : 'mcp:read mcp:write' ,
3836+ resourceMetadata : {
3837+ resource : 'https://api.example.com/' ,
3838+ scopes_supported : [ 'mcp:read' , 'mcp:write' ]
3839+ } ,
3840+ authServerMetadata : asMetadataWithOfflineAccess ,
3841+ clientMetadata : clientMetadataWithRefreshToken
3842+ } ) ;
3843+
3844+ expect ( result ) . toBe ( 'mcp:read mcp:write offline_access' ) ;
3845+ } ) ;
3846+
3847+ it ( 'adds offline_access when AS supports it and client grant_types includes refresh_token' , ( ) => {
3848+ const result = determineScope ( {
3849+ resourceMetadata : {
3850+ resource : 'https://api.example.com/' ,
3851+ scopes_supported : [ 'mcp:read' , 'mcp:write' ]
3852+ } ,
3853+ authServerMetadata : asMetadataWithOfflineAccess ,
3854+ clientMetadata : clientMetadataWithRefreshToken
3855+ } ) ;
3856+
3857+ expect ( result ) . toBe ( 'mcp:read mcp:write offline_access' ) ;
3858+ } ) ;
3859+
3860+ it ( 'adds offline_access when using clientMetadata.scope fallback' , ( ) => {
3861+ const result = determineScope ( {
3862+ authServerMetadata : asMetadataWithOfflineAccess ,
3863+ clientMetadata : {
3864+ ...clientMetadataWithRefreshToken ,
3865+ scope : 'mcp:tools'
3866+ }
3867+ } ) ;
3868+
3869+ expect ( result ) . toBe ( 'mcp:tools offline_access' ) ;
3870+ } ) ;
3871+
3872+ it ( 'does NOT augment when no other scopes are present' , ( ) => {
3873+ const result = determineScope ( {
3874+ authServerMetadata : asMetadataWithOfflineAccess ,
3875+ clientMetadata : clientMetadataWithRefreshToken
3876+ } ) ;
3877+
3878+ expect ( result ) . toBeUndefined ( ) ;
3879+ } ) ;
3880+
3881+ it ( 'does NOT augment when AS metadata lacks offline_access' , ( ) => {
3882+ const result = determineScope ( {
3883+ resourceMetadata : {
3884+ resource : 'https://api.example.com/' ,
3885+ scopes_supported : [ 'mcp:read' , 'mcp:write' ]
3886+ } ,
3887+ authServerMetadata : asMetadataWithoutOfflineAccess ,
3888+ clientMetadata : clientMetadataWithRefreshToken
3889+ } ) ;
3890+
3891+ expect ( result ) . toBe ( 'mcp:read mcp:write' ) ;
3892+ } ) ;
3893+
3894+ it ( 'does NOT augment when AS metadata is undefined' , ( ) => {
3895+ const result = determineScope ( {
3896+ resourceMetadata : {
3897+ resource : 'https://api.example.com/' ,
3898+ scopes_supported : [ 'mcp:read' , 'mcp:write' ]
3899+ } ,
3900+ clientMetadata : clientMetadataWithRefreshToken
3901+ } ) ;
3902+
3903+ expect ( result ) . toBe ( 'mcp:read mcp:write' ) ;
3904+ } ) ;
3905+
3906+ it ( 'does NOT augment when offline_access already in clientMetadata.scope' , ( ) => {
3907+ const result = determineScope ( {
3908+ authServerMetadata : asMetadataWithOfflineAccess ,
3909+ clientMetadata : {
3910+ ...clientMetadataWithRefreshToken ,
3911+ scope : 'mcp:tools offline_access'
3912+ }
3913+ } ) ;
3914+
3915+ expect ( result ) . toBe ( 'mcp:tools offline_access' ) ;
3916+ } ) ;
3917+
3918+ it ( 'does NOT augment when non-compliant PRM already includes offline_access' , ( ) => {
3919+ const result = determineScope ( {
3920+ resourceMetadata : {
3921+ resource : 'https://api.example.com/' ,
3922+ scopes_supported : [ 'mcp:read' , 'offline_access' , 'mcp:write' ]
3923+ } ,
3924+ authServerMetadata : asMetadataWithOfflineAccess ,
3925+ clientMetadata : clientMetadataWithRefreshToken
3926+ } ) ;
3927+
3928+ expect ( result ) . toBe ( 'mcp:read offline_access mcp:write' ) ;
3929+ } ) ;
3930+
3931+ it ( 'does NOT augment when grant_types omits refresh_token' , ( ) => {
3932+ const result = determineScope ( {
3933+ resourceMetadata : {
3934+ resource : 'https://api.example.com/' ,
3935+ scopes_supported : [ 'mcp:read' , 'mcp:write' ]
3936+ } ,
3937+ authServerMetadata : asMetadataWithOfflineAccess ,
3938+ clientMetadata : {
3939+ ...baseClientMetadata ,
3940+ grant_types : [ 'authorization_code' ]
3941+ }
3942+ } ) ;
3943+
3944+ expect ( result ) . toBe ( 'mcp:read mcp:write' ) ;
3945+ } ) ;
3946+
3947+ it ( 'does NOT augment when grant_types is undefined (respects OAuth defaults)' , ( ) => {
3948+ const result = determineScope ( {
3949+ resourceMetadata : {
3950+ resource : 'https://api.example.com/' ,
3951+ scopes_supported : [ 'mcp:read' , 'mcp:write' ]
3952+ } ,
3953+ authServerMetadata : asMetadataWithOfflineAccess ,
3954+ clientMetadata : baseClientMetadata
3955+ } ) ;
3956+
3957+ expect ( result ) . toBe ( 'mcp:read mcp:write' ) ;
3958+ } ) ;
3959+ } ) ;
3960+ } ) ;
37373961} ) ;
0 commit comments