@@ -1167,6 +1167,141 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide
11671167 assert oauth_provider .context .current_tokens .access_token == "new_access_token"
11681168 assert oauth_provider .context .token_expiry_time is not None
11691169
1170+ @pytest .mark .anyio
1171+ async def test_auth_flow_preserves_explicit_scopes (
1172+ self , oauth_provider : OAuthClientProvider , mock_storage : MockTokenStorage
1173+ ):
1174+ """Test that explicitly-set client_metadata.scope is not overwritten during discovery."""
1175+ oauth_provider .context .current_tokens = None
1176+ oauth_provider .context .token_expiry_time = None
1177+ oauth_provider ._initialized = True
1178+
1179+ # The fixture sets scope="read write" — verify it is preserved
1180+ assert oauth_provider .context .client_metadata .scope == "read write"
1181+
1182+ test_request = httpx .Request ("GET" , "https://api.example.com/mcp" )
1183+ auth_flow = oauth_provider .async_auth_flow (test_request )
1184+
1185+ # First request — no auth header
1186+ await auth_flow .__anext__ ()
1187+
1188+ # 401 triggers OAuth discovery
1189+ response = httpx .Response (
1190+ 401 ,
1191+ headers = {
1192+ "WWW-Authenticate" : (
1193+ 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource",'
1194+ ' scope="server:scope1 server:scope2"'
1195+ )
1196+ },
1197+ request = test_request ,
1198+ )
1199+
1200+ # PRM discovery
1201+ prm_request = await auth_flow .asend (response )
1202+ prm_response = httpx .Response (
1203+ 200 ,
1204+ content = (
1205+ b'{"resource": "https://api.example.com/v1/mcp",'
1206+ b' "authorization_servers": ["https://auth.example.com"],'
1207+ b' "scopes_supported": ["server:scope1", "server:scope2"]}'
1208+ ),
1209+ request = prm_request ,
1210+ )
1211+
1212+ # OAuth metadata discovery
1213+ oauth_request = await auth_flow .asend (prm_response )
1214+ oauth_response = httpx .Response (
1215+ 200 ,
1216+ content = (
1217+ b'{"issuer": "https://auth.example.com",'
1218+ b' "authorization_endpoint": "https://auth.example.com/authorize",'
1219+ b' "token_endpoint": "https://auth.example.com/token",'
1220+ b' "registration_endpoint": "https://auth.example.com/register"}'
1221+ ),
1222+ request = oauth_request ,
1223+ )
1224+
1225+ # After scope selection (Step 3), the explicit scope must be preserved
1226+ await auth_flow .asend (oauth_response )
1227+ assert oauth_provider .context .client_metadata .scope == "read write"
1228+
1229+ # Clean up the generator
1230+ await auth_flow .aclose ()
1231+
1232+ @pytest .mark .anyio
1233+ async def test_auth_flow_auto_selects_scopes_when_none (self , mock_storage : MockTokenStorage ):
1234+ """Test that scope auto-selection works when no explicit scope is set."""
1235+
1236+ async def redirect_handler (url : str ) -> None :
1237+ pass # pragma: no cover
1238+
1239+ async def callback_handler () -> tuple [str , str | None ]:
1240+ return "test_auth_code" , "test_state" # pragma: no cover
1241+
1242+ client_metadata = OAuthClientMetadata (
1243+ client_name = "Test Client" ,
1244+ client_uri = AnyHttpUrl ("https://example.com" ),
1245+ redirect_uris = [AnyUrl ("http://localhost:3030/callback" )],
1246+ scope = None ,
1247+ )
1248+ provider = OAuthClientProvider (
1249+ server_url = "https://api.example.com/v1/mcp" ,
1250+ client_metadata = client_metadata ,
1251+ storage = mock_storage ,
1252+ redirect_handler = redirect_handler ,
1253+ callback_handler = callback_handler ,
1254+ )
1255+ provider .context .current_tokens = None
1256+ provider .context .token_expiry_time = None
1257+ provider ._initialized = True
1258+
1259+ test_request = httpx .Request ("GET" , "https://api.example.com/mcp" )
1260+ auth_flow = provider .async_auth_flow (test_request )
1261+
1262+ await auth_flow .__anext__ ()
1263+
1264+ response = httpx .Response (
1265+ 401 ,
1266+ headers = {
1267+ "WWW-Authenticate" : (
1268+ 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource",'
1269+ ' scope="server:scope1 server:scope2"'
1270+ )
1271+ },
1272+ request = test_request ,
1273+ )
1274+
1275+ prm_request = await auth_flow .asend (response )
1276+ prm_response = httpx .Response (
1277+ 200 ,
1278+ content = (
1279+ b'{"resource": "https://api.example.com/v1/mcp",'
1280+ b' "authorization_servers": ["https://auth.example.com"],'
1281+ b' "scopes_supported": ["server:scope1", "server:scope2"]}'
1282+ ),
1283+ request = prm_request ,
1284+ )
1285+
1286+ oauth_request = await auth_flow .asend (prm_response )
1287+ oauth_response = httpx .Response (
1288+ 200 ,
1289+ content = (
1290+ b'{"issuer": "https://auth.example.com",'
1291+ b' "authorization_endpoint": "https://auth.example.com/authorize",'
1292+ b' "token_endpoint": "https://auth.example.com/token",'
1293+ b' "registration_endpoint": "https://auth.example.com/register"}'
1294+ ),
1295+ request = oauth_request ,
1296+ )
1297+
1298+ await auth_flow .asend (oauth_response )
1299+ # Scope should have been auto-selected from the server metadata
1300+ assert provider .context .client_metadata .scope is not None
1301+ assert provider .context .client_metadata .scope == "server:scope1 server:scope2"
1302+
1303+ await auth_flow .aclose ()
1304+
11701305 @pytest .mark .anyio
11711306 async def test_auth_flow_no_unnecessary_retry_after_oauth (
11721307 self , oauth_provider : OAuthClientProvider , mock_storage : MockTokenStorage , valid_tokens : OAuthToken
0 commit comments