@@ -1327,7 +1327,7 @@ async def test_403_insufficient_scope_updates_scope_from_header(
13271327 mock_storage : MockTokenStorage ,
13281328 valid_tokens : OAuthToken ,
13291329 ):
1330- """Test that 403 response correctly updates scope from WWW-Authenticate header."""
1330+ """Test that 403 response correctly accumulates scope from WWW-Authenticate header."""
13311331 # Pre-store valid tokens and client info
13321332 client_info = OAuthClientInformationFull (
13331333 client_id = "test_client_id" ,
@@ -1350,10 +1350,10 @@ async def test_403_insufficient_scope_updates_scope_from_header(
13501350 async def capture_redirect (url : str ) -> None :
13511351 nonlocal redirect_captured , captured_state
13521352 redirect_captured = True
1353- # Verify the new scope is included in authorization URL
1354- assert "scope=admin%3Awrite+admin%3Adelete" in url or "scope=admin:write+admin:delete" in url .replace (
1355- "%3A " , ":"
1356- ). replace ( "+" , " " )
1353+ # Verify the accumulated scopes are included (original + new)
1354+ decoded = url . replace ( "%3A" , ":" ) .replace ("+" , " " )
1355+ for s in [ "admin:write " , "admin:delete" , "read" , "write" ]:
1356+ assert s in decoded , f"Expected scope ' { s } ' in URL"
13571357 # Extract state from redirect URL
13581358 parsed = urlparse (url )
13591359 params = parse_qs (parsed .query )
@@ -1383,8 +1383,9 @@ async def mock_callback() -> tuple[str, str | None]:
13831383 # Trigger step-up - should get token exchange request
13841384 token_exchange_request = await auth_flow .asend (response_403 )
13851385
1386- # Verify scope was updated
1387- assert oauth_provider .context .client_metadata .scope == "admin:write admin:delete"
1386+ # Verify scope was accumulated (original "read write" + new "admin:write admin:delete")
1387+ accumulated = set (oauth_provider .context .client_metadata .scope .split ())
1388+ assert accumulated == {"admin:delete" , "admin:write" , "read" , "write" }
13881389 assert redirect_captured
13891390
13901391 # Complete the flow with successful token response
@@ -2264,3 +2265,48 @@ async def callback_handler() -> tuple[str, str | None]:
22642265 await auth_flow .asend (final_response )
22652266 except StopAsyncIteration :
22662267 pass
2268+
2269+
2270+ class TestMergeScopes :
2271+ """Tests for merge_scopes utility function."""
2272+
2273+ def test_merge_none_existing_returns_incoming (self ):
2274+ from mcp .client .auth .utils import merge_scopes
2275+
2276+ assert merge_scopes (None , "mcp:tools:read" ) == "mcp:tools:read"
2277+
2278+ def test_merge_none_incoming_returns_existing (self ):
2279+ from mcp .client .auth .utils import merge_scopes
2280+
2281+ assert merge_scopes ("init" , None ) == "init"
2282+
2283+ def test_merge_both_none_returns_none (self ):
2284+ from mcp .client .auth .utils import merge_scopes
2285+
2286+ assert merge_scopes (None , None ) is None
2287+
2288+ def test_merge_disjoint_scopes (self ):
2289+ from mcp .client .auth .utils import merge_scopes
2290+
2291+ result = merge_scopes ("init" , "mcp:tools:read" )
2292+ scopes = set (result .split ())
2293+ assert scopes == {"init" , "mcp:tools:read" }
2294+
2295+ def test_merge_overlapping_scopes_deduplicates (self ):
2296+ from mcp .client .auth .utils import merge_scopes
2297+
2298+ result = merge_scopes ("init mcp:tools:read" , "mcp:tools:read mcp:tools:write" )
2299+ scopes = set (result .split ())
2300+ assert scopes == {"init" , "mcp:tools:read" , "mcp:tools:write" }
2301+
2302+ def test_merge_identical_scopes (self ):
2303+ from mcp .client .auth .utils import merge_scopes
2304+
2305+ result = merge_scopes ("init" , "init" )
2306+ assert result == "init"
2307+
2308+ def test_merge_empty_strings (self ):
2309+ from mcp .client .auth .utils import merge_scopes
2310+
2311+ assert merge_scopes ("init" , "" ) == "init"
2312+ assert merge_scopes ("" , "init" ) == "init"
0 commit comments