diff --git a/docs/config.png b/docs/config.png index 11b5064c..db1bf1e3 100644 Binary files a/docs/config.png and b/docs/config.png differ diff --git a/docs/config.puml b/docs/config.puml index 9bc91c67..c076afa4 100644 --- a/docs/config.puml +++ b/docs/config.puml @@ -29,7 +29,7 @@ class "CORSConfiguration" as src.models.config.CORSConfiguration { class "Configuration" as src.models.config.Configuration { authentication : Optional[AuthenticationConfiguration] authorization : Optional[AuthorizationConfiguration] - conversation_cache : Optional[ConversationCache] + conversation_cache : Optional[ConversationCacheConfiguration] customization : Optional[Customization] database : Optional[DatabaseConfiguration] inference : Optional[InferenceConfiguration] @@ -43,7 +43,7 @@ class "Configuration" as src.models.config.Configuration { class "ConfigurationBase" as src.models.config.ConfigurationBase { model_config } -class "ConversationCache" as src.models.config.ConversationCache { +class "ConversationCacheConfiguration" as src.models.config.ConversationCacheConfiguration { memory : Optional[InMemoryCacheConfig] postgres : Optional[PostgreSQLDatabaseConfiguration] sqlite : Optional[SQLiteDatabaseConfiguration] @@ -157,7 +157,7 @@ src.models.config.AuthenticationConfiguration --|> src.models.config.Configurati src.models.config.AuthorizationConfiguration --|> src.models.config.ConfigurationBase src.models.config.CORSConfiguration --|> src.models.config.ConfigurationBase src.models.config.Configuration --|> src.models.config.ConfigurationBase -src.models.config.ConversationCache --|> src.models.config.ConfigurationBase +src.models.config.ConversationCacheConfiguration --|> src.models.config.ConfigurationBase src.models.config.Customization --|> src.models.config.ConfigurationBase src.models.config.DatabaseConfiguration --|> src.models.config.ConfigurationBase src.models.config.InMemoryCacheConfig --|> src.models.config.ConfigurationBase diff --git a/docs/config.svg b/docs/config.svg index cab23cd5..b99e6056 100644 --- a/docs/config.svg +++ b/docs/config.svg @@ -1,5 +1,5 @@ - + @@ -15,13 +15,13 @@ - - - - Action - - name - + + + + Action + + name + @@ -65,41 +65,41 @@ - - - - Configuration - - authentication : Optional[AuthenticationConfiguration] - authorization : Optional[AuthorizationConfiguration] - conversation_cache : Optional[ConversationCache] - customization : Optional[Customization] - database : Optional[DatabaseConfiguration] - inference : Optional[InferenceConfiguration] - llama_stack - mcp_servers : Optional[list[ModelContextProtocolServer]] - name : str - service - user_data_collection - - dump(filename: str) -> None + + + + Configuration + + authentication : Optional[AuthenticationConfiguration] + authorization : Optional[AuthorizationConfiguration] + conversation_cache : Optional[ConversationCacheConfiguration] + customization : Optional[Customization] + database : Optional[DatabaseConfiguration] + inference : Optional[InferenceConfiguration] + llama_stack + mcp_servers : Optional[list[ModelContextProtocolServer]] + name : str + service + user_data_collection + + dump(filename: str) -> None - - - - ConfigurationBase - - model_config - - - - - - - - ConversationCache + + + + ConfigurationBase + + model_config + + + + + + + + ConversationCacheConfiguration memory : Optional[InMemoryCacheConfig] postgres : Optional[PostgreSQLDatabaseConfiguration] @@ -110,354 +110,354 @@ - - - - CustomProfile - - path : str - prompts : Optional[dict[str, str]] - - get_prompts() -> dict[str, str] + + + + CustomProfile + + path : str + prompts : Optional[dict[str, str]] + + get_prompts() -> dict[str, str] - - - - Customization - - custom_profile : Optional[CustomProfile] - disable_query_system_prompt : bool - profile_path : Optional[str] - system_prompt : Optional[str] - system_prompt_path : Optional[FilePath] - - check_customization_model() -> Self + + + + Customization + + custom_profile : Optional[CustomProfile] + disable_query_system_prompt : bool + profile_path : Optional[str] + system_prompt : Optional[str] + system_prompt_path : Optional[FilePath] + + check_customization_model() -> Self - - - - DatabaseConfiguration - - config - db_type - postgres : Optional[PostgreSQLDatabaseConfiguration] - sqlite : Optional[SQLiteDatabaseConfiguration] - - check_database_configuration() -> Self + + + + DatabaseConfiguration + + config + db_type + postgres : Optional[PostgreSQLDatabaseConfiguration] + sqlite : Optional[SQLiteDatabaseConfiguration] + + check_database_configuration() -> Self - - - - InMemoryCacheConfig - - max_entries : Annotated - + + + + InMemoryCacheConfig + + max_entries : Annotated + - - - - InferenceConfiguration - - default_model : Optional[str] - default_provider : Optional[str] - - check_default_model_and_provider() -> Self + + + + InferenceConfiguration + + default_model : Optional[str] + default_provider : Optional[str] + + check_default_model_and_provider() -> Self - - - - JsonPathOperator - - name - + + + + JsonPathOperator + + name + - - - - JwkConfiguration - - jwt_configuration : Optional[JwtConfiguration] - url : AnyHttpUrl - + + + + JwkConfiguration + + jwt_configuration : Optional[JwtConfiguration] + url : AnyHttpUrl + - - - - JwtConfiguration - - role_rules : Optional[list[JwtRoleRule]] - user_id_claim : str - username_claim : str - + + + + JwtConfiguration + + role_rules : Optional[list[JwtRoleRule]] + user_id_claim : str + username_claim : str + - - - - JwtRoleRule - - compiled_regex - jsonpath : str - negate : bool - operator - roles : list[str] - value : Any - - check_jsonpath() -> Self - check_regex_pattern() -> Self - check_roles() -> Self + + + + JwtRoleRule + + compiled_regex + jsonpath : str + negate : bool + operator + roles : list[str] + value : Any + + check_jsonpath() -> Self + check_regex_pattern() -> Self + check_roles() -> Self - - - - LlamaStackConfiguration - - api_key : Optional[SecretStr] - library_client_config_path : Optional[str] - url : Optional[str] - use_as_library_client : Optional[bool] - - check_llama_stack_model() -> Self + + + + LlamaStackConfiguration + + api_key : Optional[SecretStr] + library_client_config_path : Optional[str] + url : Optional[str] + use_as_library_client : Optional[bool] + + check_llama_stack_model() -> Self - - - - ModelContextProtocolServer - - name : str - provider_id : str - url : str - + + + + ModelContextProtocolServer + + name : str + provider_id : str + url : str + - - - - PostgreSQLDatabaseConfiguration - - ca_cert_path : Optional[FilePath] - db : str - gss_encmode : str - host : str - namespace : Optional[str] - password : SecretStr - port : Annotated - ssl_mode : str - user : str - - check_postgres_configuration() -> Self + + + + PostgreSQLDatabaseConfiguration + + ca_cert_path : Optional[FilePath] + db : str + gss_encmode : str + host : str + namespace : Optional[str] + password : SecretStr + port : Annotated + ssl_mode : str + user : str + + check_postgres_configuration() -> Self - - - - SQLiteDatabaseConfiguration - - db_path : str - + + + + SQLiteDatabaseConfiguration + + db_path : str + - - - - ServiceConfiguration - - access_log : bool - auth_enabled : bool - color_log : bool - cors : Optional[CORSConfiguration] - host : str - port : Annotated - tls_config : Optional[TLSConfiguration] - workers : Annotated - - check_service_configuration() -> Self + + + + ServiceConfiguration + + access_log : bool + auth_enabled : bool + color_log : bool + cors : Optional[CORSConfiguration] + host : str + port : Annotated + tls_config : Optional[TLSConfiguration] + workers : Annotated + + check_service_configuration() -> Self - - - - TLSConfiguration - - tls_certificate_path : Optional[FilePath] - tls_key_password : Optional[FilePath] - tls_key_path : Optional[FilePath] - - check_tls_configuration() -> Self + + + + TLSConfiguration + + tls_certificate_path : Optional[FilePath] + tls_key_password : Optional[FilePath] + tls_key_path : Optional[FilePath] + + check_tls_configuration() -> Self - - - - UserDataCollection - - feedback_enabled : bool - feedback_storage : Optional[str] - transcripts_enabled : bool - transcripts_storage : Optional[str] - - check_storage_location_is_set_when_needed() -> Self + + + + UserDataCollection + + feedback_enabled : bool + feedback_storage : Optional[str] + transcripts_enabled : bool + transcripts_storage : Optional[str] + + check_storage_location_is_set_when_needed() -> Self - - + + - - + + - - + + - - + + - - + + - - - - + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - custom_profile + + + custom_profile - - - operator + + + operator - - - llama_stack + + + llama_stack - - - sqlite + + + sqlite - - - service + + + service - - - user_data_collection + + + user_data_collection - + diff --git a/src/cache/__init__.py b/src/cache/__init__.py new file mode 100644 index 00000000..a9741b32 --- /dev/null +++ b/src/cache/__init__.py @@ -0,0 +1 @@ +"""Various cache implementations.""" diff --git a/src/cache/cache.py b/src/cache/cache.py new file mode 100644 index 00000000..f9e55388 --- /dev/null +++ b/src/cache/cache.py @@ -0,0 +1,110 @@ +"""Abstract class that is parent for all cache implementations.""" + +from abc import ABC, abstractmethod + +from models.cache_entry import CacheEntry +from utils.suid import check_suid + + +class Cache(ABC): + """Abstract class that is parent for all cache implementations. + + Cache entries are identified by compound key that consists of + user ID and conversation ID. Application logic must ensure that + user will be able to store and retrieve values that have the + correct user ID only. This means that user won't be able to + read or modify other users conversations. + """ + + # separator between parts of compond key + COMPOUND_KEY_SEPARATOR = ":" + + @staticmethod + def _check_user_id(user_id: str, skip_user_id_check: bool) -> None: + """Check if given user ID is valid.""" + if skip_user_id_check: + return + if not check_suid(user_id): + raise ValueError(f"Invalid user ID {user_id}") + + @staticmethod + def _check_conversation_id(conversation_id: str) -> None: + """Check if given conversation ID is a valid UUID (including optional dashes).""" + if not check_suid(conversation_id): + raise ValueError(f"Invalid conversation ID {conversation_id}") + + @staticmethod + def construct_key( + user_id: str, conversation_id: str, skip_user_id_check: bool + ) -> str: + """Construct key to cache.""" + Cache._check_user_id(user_id, skip_user_id_check) + Cache._check_conversation_id(conversation_id) + return f"{user_id}{Cache.COMPOUND_KEY_SEPARATOR}{conversation_id}" + + @abstractmethod + def get( + self, user_id: str, conversation_id: str, skip_user_id_check: bool + ) -> list[CacheEntry]: + """Abstract method to retrieve a value from the cache. + + Args: + user_id: User identification. + conversation_id: Conversation ID unique for given user. + skip_user_id_check: Skip user_id suid check. + + Returns: + The value (CacheEntry(s)) associated with the key, or None if not found. + """ + + @abstractmethod + def insert_or_append( + self, + user_id: str, + conversation_id: str, + cache_entry: CacheEntry, + skip_user_id_check: bool, + ) -> None: + """Abstract method to store a value in the cache. + + Args: + user_id: User identification. + conversation_id: Conversation ID unique for given user. + cache_entry: The value to store. + skip_user_id_check: Skip user_id suid check. + """ + + @abstractmethod + def delete( + self, user_id: str, conversation_id: str, skip_user_id_check: bool + ) -> bool: + """Delete all entries for a given conversation. + + Args: + user_id: User identification. + conversation_id: Conversation ID unique for given user. + skip_user_id_check: Skip user_id suid check. + + Returns: + bool: True if entries were deleted, False if key wasn't found. + """ + + @abstractmethod + def list(self, user_id: str, skip_user_id_check: bool) -> list[str]: + """List all conversations for a given user_id. + + Args: + user_id: User identification. + skip_user_id_check: Skip user_id suid check. + + Returns: + A list of conversation ids from the cache + """ + + @abstractmethod + def ready(self) -> bool: + """Check if the cache is ready. + + Returns: + True if the cache is ready, False otherwise. + """ diff --git a/src/cache/cache_error.py b/src/cache/cache_error.py new file mode 100644 index 00000000..76e6c3ff --- /dev/null +++ b/src/cache/cache_error.py @@ -0,0 +1,5 @@ +"""Any exception that can occur during cache operations.""" + + +class CacheError(Exception): + """Any exception that can occur during cache operations.""" diff --git a/src/cache/cache_factory.py b/src/cache/cache_factory.py new file mode 100644 index 00000000..0c02fe5a --- /dev/null +++ b/src/cache/cache_factory.py @@ -0,0 +1,35 @@ +"""Cache factory class.""" + +import constants +from models.config import ConversationCacheConfiguration +from cache.cache import Cache +from log import get_logger + +logger = get_logger("cache.cache_factory") + + +# pylint: disable=R0903 +class CacheFactory: + """Cache factory class.""" + + @staticmethod + def conversation_cache(config: ConversationCacheConfiguration) -> Cache | None: + """Create an instance of Cache based on loaded configuration. + + Returns: + An instance of `Cache` (either `PostgresCache` or `InMemoryCache`). + """ + logger.info("Creating cache instance of type %s", config.type) + match config.type: + case constants.CACHE_TYPE_MEMORY: + return None + case constants.CACHE_TYPE_SQLITE: + return None + case constants.CACHE_TYPE_POSTGRES: + return None + case _: + raise ValueError( + f"Invalid cache type: {config.type}. " + f"Use '{constants.CACHE_TYPE_POSTGRES}' '{constants.CACHE_TYPE_SQLITE}' or " + f"'{constants.CACHE_TYPE_MEMORY}' options." + ) diff --git a/src/configuration.py b/src/configuration.py index c6193ab6..00bb3174 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -19,9 +19,12 @@ AuthenticationConfiguration, InferenceConfiguration, DatabaseConfiguration, - ConversationCache, + ConversationCacheConfiguration, ) +from cache.cache import Cache +from cache.cache_factory import CacheFactory + logger = logging.getLogger(__name__) @@ -44,6 +47,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> "AppConfig": def __init__(self) -> None: """Initialize the class instance.""" self._configuration: Optional[Configuration] = None + self._conversation_cache: Optional[Cache] = None def load_configuration(self, filename: str) -> None: """Load configuration from YAML file.""" @@ -126,7 +130,7 @@ def inference(self) -> InferenceConfiguration: return self._configuration.inference @property - def conversation_cache(self) -> ConversationCache: + def conversation_cache_configuration(self) -> ConversationCacheConfiguration: """Return conversation cache configuration.""" if self._configuration is None: raise LogicError("logic error: configuration is not loaded") @@ -139,5 +143,14 @@ def database_configuration(self) -> DatabaseConfiguration: raise LogicError("logic error: configuration is not loaded") return self._configuration.database + @property + def conversation_cache(self) -> Cache | None: + """Return the conversation cache.""" + if self._conversation_cache is None and self._configuration is not None: + self._conversation_cache = CacheFactory.conversation_cache( + self._configuration.conversation_cache + ) + return self._conversation_cache + configuration: AppConfig = AppConfig() diff --git a/src/models/cache_entry.py b/src/models/cache_entry.py new file mode 100644 index 00000000..810bad71 --- /dev/null +++ b/src/models/cache_entry.py @@ -0,0 +1,19 @@ +"""Model for conversation history cache entry.""" + +from pydantic import BaseModel + + +class CacheEntry(BaseModel): + """Model representing a cache entry. + + Attributes: + query: The query string + response: The response string + provider: Provider identification + model: Model identification + """ + + query: str + response: str + provider: str + model: str diff --git a/src/models/config.py b/src/models/config.py index 9260a078..b17900db 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -486,7 +486,7 @@ def check_default_model_and_provider(self) -> Self: return self -class ConversationCache(ConfigurationBase): +class ConversationCacheConfiguration(ConfigurationBase): """Conversation cache configuration.""" type: Literal["memory", "sqlite", "postgres"] | None = None @@ -542,7 +542,9 @@ class Configuration(ConfigurationBase): authorization: Optional[AuthorizationConfiguration] = None customization: Optional[Customization] = None inference: InferenceConfiguration = Field(default_factory=InferenceConfiguration) - conversation_cache: ConversationCache = Field(default_factory=ConversationCache) + conversation_cache: ConversationCacheConfiguration = Field( + default_factory=ConversationCacheConfiguration + ) def dump(self, filename: str = "configuration.json") -> None: """Dump actual configuration into JSON file.""" diff --git a/tests/unit/models/config/test_conversation_cache.py b/tests/unit/models/config/test_conversation_cache.py index b410506d..5af79434 100644 --- a/tests/unit/models/config/test_conversation_cache.py +++ b/tests/unit/models/config/test_conversation_cache.py @@ -1,4 +1,4 @@ -"""Unit tests for ConversationCache model.""" +"""Unit tests for ConversationCacheConfiguration model.""" from pathlib import Path @@ -8,7 +8,7 @@ import constants from models.config import ( - ConversationCache, + ConversationCacheConfiguration, InMemoryCacheConfig, SQLiteDatabaseConfiguration, PostgreSQLDatabaseConfiguration, @@ -17,7 +17,7 @@ def test_conversation_cache_no_type_specified() -> None: """Check the test for type as optional attribute.""" - c = ConversationCache() + c = ConversationCacheConfiguration() assert c.type is None @@ -26,7 +26,7 @@ def test_conversation_cache_unknown_type() -> None: with pytest.raises( ValidationError, match="Input should be 'memory', 'sqlite' or 'postgres'" ): - _ = ConversationCache(type="foo") + _ = ConversationCacheConfiguration(type="foo") def test_conversation_cache_correct_type_but_not_configured(subtests) -> None: @@ -35,19 +35,19 @@ def test_conversation_cache_correct_type_but_not_configured(subtests) -> None: with pytest.raises( ValidationError, match="Memory cache is selected, but not configured" ): - _ = ConversationCache(type=constants.CACHE_TYPE_MEMORY) + _ = ConversationCacheConfiguration(type=constants.CACHE_TYPE_MEMORY) with subtests.test(msg="SQLite cache"): with pytest.raises( ValidationError, match="SQLite cache is selected, but not configured" ): - _ = ConversationCache(type=constants.CACHE_TYPE_SQLITE) + _ = ConversationCacheConfiguration(type=constants.CACHE_TYPE_SQLITE) with subtests.test(msg="SQLite cache"): with pytest.raises( ValidationError, match="PostgreSQL cache is selected, but not configured" ): - _ = ConversationCache(type=constants.CACHE_TYPE_POSTGRES) + _ = ConversationCacheConfiguration(type=constants.CACHE_TYPE_POSTGRES) def test_conversation_cache_no_type_but_configured(subtests) -> None: @@ -56,11 +56,15 @@ def test_conversation_cache_no_type_but_configured(subtests) -> None: with subtests.test(msg="Memory cache"): with pytest.raises(ValidationError, match=m): - _ = ConversationCache(memory=InMemoryCacheConfig(max_entries=100)) + _ = ConversationCacheConfiguration( + memory=InMemoryCacheConfig(max_entries=100) + ) with subtests.test(msg="SQLite cache"): with pytest.raises(ValidationError, match=m): - _ = ConversationCache(sqlite=SQLiteDatabaseConfiguration(db_path="path")) + _ = ConversationCacheConfiguration( + sqlite=SQLiteDatabaseConfiguration(db_path="path") + ) with subtests.test(msg="PostgreSQL cache"): d = PostgreSQLDatabaseConfiguration( @@ -71,7 +75,7 @@ def test_conversation_cache_no_type_but_configured(subtests) -> None: ca_cert_path=Path("tests/configuration/server.crt"), ) with pytest.raises(ValidationError, match=m): - _ = ConversationCache(postgres=d) + _ = ConversationCacheConfiguration(postgres=d) def test_conversation_cache_multiple_configurations(subtests) -> None: @@ -88,7 +92,7 @@ def test_conversation_cache_multiple_configurations(subtests) -> None: with pytest.raises( ValidationError, match="Only memory cache config must be provided" ): - _ = ConversationCache( + _ = ConversationCacheConfiguration( type=constants.CACHE_TYPE_MEMORY, memory=InMemoryCacheConfig(max_entries=100), sqlite=SQLiteDatabaseConfiguration(db_path="path"), @@ -99,7 +103,7 @@ def test_conversation_cache_multiple_configurations(subtests) -> None: with pytest.raises( ValidationError, match="Only SQLite cache config must be provided" ): - _ = ConversationCache( + _ = ConversationCacheConfiguration( type=constants.CACHE_TYPE_SQLITE, memory=InMemoryCacheConfig(max_entries=100), sqlite=SQLiteDatabaseConfiguration(db_path="path"), @@ -110,7 +114,7 @@ def test_conversation_cache_multiple_configurations(subtests) -> None: with pytest.raises( ValidationError, match="Only PostgreSQL cache config must be provided" ): - _ = ConversationCache( + _ = ConversationCacheConfiguration( type=constants.CACHE_TYPE_POSTGRES, memory=InMemoryCacheConfig(max_entries=100), sqlite=SQLiteDatabaseConfiguration(db_path="path"), @@ -120,7 +124,7 @@ def test_conversation_cache_multiple_configurations(subtests) -> None: def test_conversation_type_memory() -> None: """Test the memory conversation cache configuration.""" - c = ConversationCache( + c = ConversationCacheConfiguration( type=constants.CACHE_TYPE_MEMORY, memory=InMemoryCacheConfig(max_entries=100) ) assert c.type == constants.CACHE_TYPE_MEMORY @@ -133,13 +137,13 @@ def test_conversation_type_memory() -> None: def test_conversation_type_memory_wrong_config() -> None: """Test the memory conversation cache configuration.""" with pytest.raises(ValidationError, match="Field required"): - _ = ConversationCache( + _ = ConversationCacheConfiguration( type=constants.CACHE_TYPE_MEMORY, memory=InMemoryCacheConfig(), ) with pytest.raises(ValidationError, match="Input should be greater than 0"): - _ = ConversationCache( + _ = ConversationCacheConfiguration( type=constants.CACHE_TYPE_MEMORY, memory=InMemoryCacheConfig(max_entries=-100), ) @@ -147,7 +151,7 @@ def test_conversation_type_memory_wrong_config() -> None: def test_conversation_type_sqlite() -> None: """Test the SQLite conversation cache configuration.""" - c = ConversationCache( + c = ConversationCacheConfiguration( type=constants.CACHE_TYPE_SQLITE, sqlite=SQLiteDatabaseConfiguration(db_path="path"), ) @@ -161,7 +165,7 @@ def test_conversation_type_sqlite() -> None: def test_conversation_type_sqlite_wrong_config() -> None: """Test the SQLite conversation cache configuration.""" with pytest.raises(ValidationError, match="Field required"): - _ = ConversationCache( + _ = ConversationCacheConfiguration( type=constants.CACHE_TYPE_SQLITE, memory=SQLiteDatabaseConfiguration(), ) @@ -177,7 +181,7 @@ def test_conversation_type_postgres() -> None: ca_cert_path=Path("tests/configuration/server.crt"), ) - c = ConversationCache( + c = ConversationCacheConfiguration( type=constants.CACHE_TYPE_POSTGRES, postgres=d, ) @@ -194,7 +198,7 @@ def test_conversation_type_postgres() -> None: def test_conversation_type_postgres_wrong_config() -> None: """Test the SQLite conversation cache configuration.""" with pytest.raises(ValidationError, match="Field required"): - _ = ConversationCache( + _ = ConversationCacheConfiguration( type=constants.CACHE_TYPE_POSTGRES, postgres=PostgreSQLDatabaseConfiguration(), ) diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 11a90165..26946dc0 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -71,7 +71,7 @@ def test_default_configuration() -> None: with pytest.raises(Exception, match="logic error: configuration is not loaded"): # try to read property - _ = cfg.conversation_cache # pylint: disable=pointless-statement + _ = cfg.conversation_cache_configuration # pylint: disable=pointless-statement def test_configuration_is_singleton() -> None: @@ -149,7 +149,7 @@ def test_init_from_dict() -> None: assert cfg.inference is not None # check conversation cache - assert cfg.conversation_cache is not None + assert cfg.conversation_cache_configuration is not None def test_init_from_dict_with_mcp_servers() -> None: