diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 6bf15b531f..f4cde4a25d 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -71,6 +71,8 @@ class OAuthClientMetadata(BaseModel): def validate_scope(self, requested_scope: str | None) -> list[str] | None: if requested_scope is None: return None + if requested_scope == "": + return [] requested_scopes = requested_scope.split(" ") allowed_scopes = [] if self.scope is None else self.scope.split(" ") for scope in requested_scopes: diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 6e58e496d3..3ffa7303a7 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -467,6 +467,29 @@ async def test_refresh_token_request(self, oauth_provider: OAuthClientProvider, class TestProtectedResourceMetadata: """Test protected resource handling.""" + @pytest.mark.anyio + async def test_client_metadata_validate_scopes_none(self, client_metadata): + """Test that validate_scopes method handles None and empty string correctly.""" + # Should return None + requested_scopes = client_metadata.validate_scope(None) + assert requested_scopes is None + + # No scopes should be requested; this can happen when a client authorizes with "&scope=". + requested_scopes = client_metadata.validate_scope("") + assert requested_scopes == [] + + @pytest.mark.anyio + async def test_state_parameter_validation_uses_constant_time( + self, oauth_provider, oauth_metadata, oauth_client_info + ): + """Test that state parameter validation uses constant-time comparison.""" + oauth_provider._metadata = oauth_metadata + oauth_provider._client_info = oauth_client_info + + # Mock callback handler to return mismatched state + async def mock_callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "wrong_state" + @pytest.mark.anyio async def test_resource_param_included_with_recent_protocol_version(self, oauth_provider: OAuthClientProvider): """Test resource parameter is included for protocol version >= 2025-06-18."""