diff --git a/.circleci/scripts/run_integration.sh b/.circleci/scripts/run_integration.sh index ba24e66ba1cd..c3ad80d54288 100644 --- a/.circleci/scripts/run_integration.sh +++ b/.circleci/scripts/run_integration.sh @@ -169,7 +169,7 @@ start_proxy() { INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \ LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" LITELLM_SALT_KEY="$LITELLM_SALT_KEY" LITELLM_UI_PATH="$LITELLM_UI_PATH" PROXY_BASE_URL="http://127.0.0.1:$port" \ LITELLM_LICENSE="${LITELLM_LICENSE:-}" \ - LITELLM_MODE=PRODUCTION STORE_MODEL_IN_DB=True "${cost_map_env[@]}" \ + LITELLM_MODE=PRODUCTION STORE_MODEL_IN_DB=True LITELLM_ENABLE_MCP_STDIO=true "${cost_map_env[@]}" \ AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 COVERAGE_FILE="$coverage_data" \ "${proxy_command[@]}" --config tests/integration/proxy_config.yaml \ --host 127.0.0.1 --port "$port" --num_workers 1 --telemetry False \ diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index fe3948a80c54..652fc7f47954 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -142,6 +142,12 @@ from litellm.proxy._experimental.mcp_server.sampling_handler import ( MCP_SAMPLING_AVAILABLE, ) +from litellm.proxy._experimental.mcp_server.stdio_gate import ( + MCP_STDIO_DISABLED_MESSAGE, + is_mcp_stdio_blocked, + is_mcp_stdio_enabled, + warn_if_mcp_stdio_blocked, +) from litellm.proxy._experimental.mcp_server.tool_catalog_guard import ( CatalogAlert, apply_description_overrides, @@ -2464,6 +2470,7 @@ async def load_servers_from_config( alias=alias, server_name=server_name, ) + warn_if_mcp_stdio_blocked(server_name, server_config.get("transport")) auth_type = server_config.get("auth_type", None) manual_issuer = _blank_to_none(server_config.get("issuer")) @@ -3282,6 +3289,7 @@ async def add_server(self, mcp_server: LiteLLM_MCPServerTable): # `credentials` field is the only one still encrypted here). # Re-decrypting plaintext would zero the values, so build with # env_vars_are_encrypted=False. + self._warn_if_newly_blocked_stdio(mcp_server, None) new_server: Final = await self.build_mcp_server_from_table(mcp_server, env_vars_are_encrypted=False) self._assign_unique_short_prefix(new_server) self._invalidate_server_definition_caches(mcp_server.server_id) @@ -4280,6 +4288,8 @@ async def _create_mcp_client( # Handle stdio transport if transport == MCPTransport.stdio: + if not is_mcp_stdio_enabled(): + raise HTTPException(status_code=403, detail=MCP_STDIO_DISABLED_MESSAGE) resolved_env: Final = ( stdio_env if stdio_env is not None @@ -4439,6 +4449,9 @@ async def _get_tools_from_server( global_mcp_tool_registry, ) + if self._skip_blocked_stdio_listing(server, "tool"): + return [] + verbose_logger.debug("Connecting to url: %s", server.url) verbose_logger.info("_get_tools_from_server for %s...", server.name) @@ -4638,6 +4651,19 @@ def _discovery_key( ) return server.server_id, hashlib.sha256(material.encode()).hexdigest() + @staticmethod + def _warn_if_newly_blocked_stdio(row: LiteLLM_MCPServerTable, previous: MCPServer | None) -> None: + if previous is None or previous.transport != row.transport: + warn_if_mcp_stdio_blocked(row.alias or row.server_name, row.transport) + + def _skip_blocked_stdio_listing(self, server: MCPServer, listing: str) -> bool: + if not is_mcp_stdio_blocked(server.transport): + return False + verbose_logger.debug( + "Skipping %s listing for MCP server %s: %s", listing, server.name, MCP_STDIO_DISABLED_MESSAGE + ) + return True + async def get_prompts_from_server( self, server: MCPServer, @@ -4648,6 +4674,8 @@ async def get_prompts_from_server( raw_headers: dict[str, str] | None = None, client_ip: str | None = None, ) -> list[Prompt]: + if self._skip_blocked_stdio_listing(server, "prompt"): + return [] try: headers: Final = ( dict( @@ -4694,6 +4722,8 @@ async def get_resources_from_server( raw_headers: dict[str, str] | None = None, client_ip: str | None = None, ) -> list[Resource]: + if self._skip_blocked_stdio_listing(server, "resource"): + return [] try: headers: Final = ( dict( @@ -4740,6 +4770,8 @@ async def get_resource_templates_from_server( raw_headers: dict[str, str] | None = None, client_ip: str | None = None, ) -> list[ResourceTemplate]: + if self._skip_blocked_stdio_listing(server, "resource template"): + return [] try: headers: Final = ( dict( @@ -6324,6 +6356,8 @@ def _candidate_matches_server_name(candidate: MCPServer) -> bool: mcp_server = fallback if mcp_server is None: raise ValueError(f"Tool {name} not found") + if is_mcp_stdio_blocked(mcp_server.transport): + raise HTTPException(status_code=403, detail=MCP_STDIO_DISABLED_MESSAGE) if resolved_by_server_name_only and not self.server_exposes_tool(mcp_server, name): raise ValueError(f"Tool {name} not found") @@ -6708,7 +6742,10 @@ def _get_mcp_server_from_tool_name(self, tool_name: str) -> MCPServer | None: if matched is not None: matched_prefix, original_tool_name = matched matched_server: Final = prefix_to_server.get(matched_prefix) - if matched_server is not None and self.server_exposes_tool(matched_server, original_tool_name): + if matched_server is not None and ( + self.server_exposes_tool(matched_server, original_tool_name) + or is_mcp_stdio_blocked(matched_server.transport) + ): return matched_server return None @@ -6770,6 +6807,7 @@ async def reload_servers_from_database(self): alias=getattr(server, "alias", None), server_name=getattr(server, "server_name", None), ) + self._warn_if_newly_blocked_stdio(server, existing_server) verbose_logger.debug("Building server from DB: %s (%s)", server.server_id, server.server_name) # raw_rows come straight from the DB, so their global env var # values (like credentials) are still encrypted here, unlike the diff --git a/litellm/proxy/_experimental/mcp_server/stdio_gate.py b/litellm/proxy/_experimental/mcp_server/stdio_gate.py new file mode 100644 index 000000000000..00fde229e2f7 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/stdio_gate.py @@ -0,0 +1,28 @@ +import os +from typing import Final + +from litellm._logging import verbose_logger +from litellm.types.mcp import MCPTransport + +MCP_STDIO_ENABLED_ENV_VAR: Final = "LITELLM_ENABLE_MCP_STDIO" +MCP_STDIO_DISABLED_MESSAGE: Final = ( + f"stdio MCP servers are disabled on this proxy. " + f"Set {MCP_STDIO_ENABLED_ENV_VAR}=true on the proxy and restart to enable them" +) + + +def is_mcp_stdio_enabled() -> bool: + return os.getenv(MCP_STDIO_ENABLED_ENV_VAR, "").strip().lower() == "true" + + +def is_mcp_stdio_flag_key(env_var_name: str) -> bool: + return env_var_name.upper() == MCP_STDIO_ENABLED_ENV_VAR + + +def is_mcp_stdio_blocked(transport: str | None) -> bool: + return transport == MCPTransport.stdio and not is_mcp_stdio_enabled() + + +def warn_if_mcp_stdio_blocked(server_name: str | None, transport: str | None) -> None: + if is_mcp_stdio_blocked(transport): + verbose_logger.warning("MCP server '%s' will not start: %s", server_name, MCP_STDIO_DISABLED_MESSAGE) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index a471fb6f6f81..cf8f0b117a60 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -28,6 +28,7 @@ validate_langfuse_span_scope_value, validate_no_callback_env_reference, ) +from litellm.proxy._experimental.mcp_server.stdio_gate import MCP_STDIO_DISABLED_MESSAGE, is_mcp_stdio_enabled from litellm.types.agents import AgentCaller, AgentResponse from litellm.types.integrations.compression_interception import ( CompressionSavingsMetadata, @@ -1529,6 +1530,30 @@ def _reject_unsupported_per_server_oauth_discovery(values: object, require_auth_ raise _per_server_oauth_discovery_error() +def _validate_mcp_transport_fields(values: object) -> None: + if not isinstance(values, dict): + return + transport: Final = values.get("transport") + if transport in (MCPTransport.http, MCPTransport.sse): + if not values.get("url") and not values.get("spec_path"): + raise ValueError("url or spec_path is required for HTTP/SSE transport") + return + if transport != MCPTransport.stdio: + return + if not is_mcp_stdio_enabled(): + raise ValueError(MCP_STDIO_DISABLED_MESSAGE) + command: Final = values.get("command") + if not command: + raise ValueError("command is required for stdio transport") + if not values.get("args"): + raise ValueError("args is required for stdio transport") + if os.path.basename(str(command)) not in MCP_STDIO_ALLOWED_COMMANDS: + raise ValueError( + f"Command '{command}' is not in the allowed commands list " + f"for stdio transport. Allowed commands: {sorted(MCP_STDIO_ALLOWED_COMMANDS)}" + ) + + class NewMCPServerRequest(LiteLLMPydanticObjectBase): server_id: str | None = None server_name: str | None = None @@ -1595,23 +1620,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): @model_validator(mode="before") @classmethod def validate_transport_fields(cls, values): - if isinstance(values, dict): - transport: Final = values.get("transport") - if transport == MCPTransport.stdio: - if not values.get("command"): - raise ValueError("command is required for stdio transport") - if not values.get("args"): - raise ValueError("args is required for stdio transport") - # Validate command against allowlist to prevent arbitrary execution - base_command: Final = os.path.basename(values["command"]) - if base_command not in MCP_STDIO_ALLOWED_COMMANDS: - raise ValueError( - f"Command '{values['command']}' is not in the allowed commands list " - f"for stdio transport. Allowed commands: {sorted(MCP_STDIO_ALLOWED_COMMANDS)}" - ) - elif transport in [MCPTransport.http, MCPTransport.sse]: - if not values.get("url") and not values.get("spec_path"): - raise ValueError("url or spec_path is required for HTTP/SSE transport") + _validate_mcp_transport_fields(values) return values @model_validator(mode="before") @@ -1694,23 +1703,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): @model_validator(mode="before") @classmethod def validate_transport_fields(cls, values): - if isinstance(values, dict): - transport: Final = values.get("transport") - if transport == MCPTransport.stdio: - if not values.get("command"): - raise ValueError("command is required for stdio transport") - if not values.get("args"): - raise ValueError("args is required for stdio transport") - # Validate command against allowlist to prevent arbitrary execution - base_command: Final = os.path.basename(values["command"]) - if base_command not in MCP_STDIO_ALLOWED_COMMANDS: - raise ValueError( - f"Command '{values['command']}' is not in the allowed commands list " - f"for stdio transport. Allowed commands: {sorted(MCP_STDIO_ALLOWED_COMMANDS)}" - ) - elif transport in [MCPTransport.http, MCPTransport.sse]: - if not values.get("url") and not values.get("spec_path"): - raise ValueError("url or spec_path is required for HTTP/SSE transport") + _validate_mcp_transport_fields(values) return values @model_validator(mode="before") diff --git a/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py b/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py index 8b042d18cd03..4c85d6148c0e 100644 --- a/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py +++ b/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py @@ -4,6 +4,7 @@ from fastapi import APIRouter +from litellm.proxy._experimental.mcp_server.stdio_gate import is_mcp_stdio_enabled from litellm.proxy.common_utils.html_forms.default_credentials_hint import should_hide_default_credentials_hint from litellm.types.proxy.discovery_endpoints.ui_discovery_endpoints import ( UiDiscoveryEndpoints, @@ -41,4 +42,5 @@ async def get_ui_config(): hide_default_credentials_hint=hide_default_credentials_hint, is_control_plane=is_control_plane, workers=proxy_config.worker_registry if is_control_plane else [], + mcp_stdio_enabled=is_mcp_stdio_enabled(), ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9abf949ec1e3..be03b3bd7d61 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -335,6 +335,7 @@ def generate_feedback_box(): from litellm.llms.openai_like.model_info import MODEL_INFO_REFRESH_SECONDS from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.proxy._experimental.mcp_server.byok_credential_cache import byok_credential_cache +from litellm.proxy._experimental.mcp_server.stdio_gate import MCP_STDIO_ENABLED_ENV_VAR, is_mcp_stdio_flag_key from litellm.proxy._lazy_features import attach_lazy_features, reserve_lazy_slot from litellm.proxy._types import * from litellm.proxy.analytics_endpoints.analytics_endpoints import ( @@ -5401,6 +5402,7 @@ def __init__(self) -> None: self._last_cyberark_config: dict[str, object] | None = None # mutable-ok: change-detection cache self._last_cleanup_schedule_attempt: tuple[object, ...] | None = None self._cleanup_reschedule_failed: bool = False + self._warned_db_mcp_stdio_flag_ignored: bool = False self._cyberark_boot_env: dict[str, str | None] | None = None # mutable-ok: deployment env snapshot, set once self.worker_registry: list[WorkerRegistryEntry] = [] self.config_sync_subscriber: ConfigSyncSubscriber | None = None @@ -6184,6 +6186,12 @@ def _load_environment_variables(self, config: dict): if key in self._BLOCKED_ENV_KEYS: verbose_proxy_logger.warning("Skipping blocked environment variable key: %s", key) continue + if isinstance(key, str) and is_mcp_stdio_flag_key(key): + verbose_proxy_logger.warning( + "Ignoring %s set in the config file. Set it in the proxy's environment instead", + MCP_STDIO_ENABLED_ENV_VAR, + ) + continue ######################################################### # handles this scenario: # ```yaml @@ -7585,6 +7593,14 @@ def _decrypt_and_set_db_env_variables( """ decrypted_env_vars: Final = {} for k, v in environment_variables.items(): + if isinstance(k, str) and is_mcp_stdio_flag_key(k): + if not self._warned_db_mcp_stdio_flag_ignored: + verbose_proxy_logger.warning( + "Ignoring %s stored in the database. Set it in the proxy's environment instead", + MCP_STDIO_ENABLED_ENV_VAR, + ) + self._warned_db_mcp_stdio_flag_ignored = True + continue try: decrypted_value = decrypt_value_helper(value=v, key=k, return_original_value=return_original_value) if decrypted_value is not None: diff --git a/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py b/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py index d52877b7c1e1..4e0faabc1324 100644 --- a/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py +++ b/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py @@ -11,4 +11,5 @@ class UiDiscoveryEndpoints(BaseModel): sso_configured: bool hide_default_credentials_hint: bool = False is_control_plane: bool = False + mcp_stdio_enabled: bool = False workers: list[WorkerRegistryEntry] = [] diff --git a/tests/mcp_tests/test_proxy_mcp_e2e.py b/tests/mcp_tests/test_proxy_mcp_e2e.py index a730f6c10eef..92f26e4ab7e6 100644 --- a/tests/mcp_tests/test_proxy_mcp_e2e.py +++ b/tests/mcp_tests/test_proxy_mcp_e2e.py @@ -55,6 +55,7 @@ def _clear_proxy_database_env() -> typing.Iterator[None]: # the config file. We must set it here so the lifespan doesn't reset it to None. mp.setenv("LITELLM_MASTER_KEY", "sk-1234") mp.setenv("LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY", "true") + mp.setenv("LITELLM_ENABLE_MCP_STDIO", "true") try: yield finally: diff --git a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py index a900ad50dfb3..46264de738a3 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -314,8 +314,9 @@ def test_get_mcp_server_by_id_rejects_private_server_for_public_ip(self): with patch.object(manager, "_get_general_settings", return_value={}): assert manager.get_mcp_server_by_id(server.server_id, client_ip="8.8.8.8") is None - async def test_create_mcp_client_stdio(self): + async def test_create_mcp_client_stdio(self, monkeypatch): """Test creating MCP client for stdio transport""" + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") manager = MCPServerManager() stdio_server = MCPServer( @@ -458,11 +459,12 @@ async def test_half_configured_id_jag_fails_closed_instead_of_deferring_to_v1(se assert exc_info.value.status_code == 500 assert "oauth2_id_jag" in str(exc_info.value.detail) - async def test_create_mcp_client_stdio_injects_npm_config_cache(self): + async def test_create_mcp_client_stdio_injects_npm_config_cache(self, monkeypatch): """Test that _create_mcp_client injects NPM_CONFIG_CACHE when not already set, and preserves user-provided NPM_CONFIG_CACHE when present.""" from litellm.constants import MCP_NPM_CACHE_DIR + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") manager = MCPServerManager() # Case 1: NPM_CONFIG_CACHE not set -> should be injected @@ -491,6 +493,173 @@ async def test_create_mcp_client_stdio_injects_npm_config_cache(self): client2 = await manager._create_mcp_client(server_with_cache) assert client2.stdio_config["env"]["NPM_CONFIG_CACHE"] == "/custom/cache" + async def test_create_mcp_client_refuses_to_start_a_stdio_server_while_stdio_is_not_enabled(self, monkeypatch): + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + manager = MCPServerManager() + server = MCPServer( + server_id="stdio-off", + name="stdio_off", + transport=MCPTransport.stdio, + command="python", + args=["server.py"], + ) + + with pytest.raises(HTTPException) as exc_info: + await manager._create_mcp_client(server) + + assert exc_info.value.status_code == 403 + assert "LITELLM_ENABLE_MCP_STDIO=true" in str(exc_info.value.detail) + + @pytest.mark.parametrize( + "listing", + [ + lambda manager, server: manager._get_tools_from_server(server), + lambda manager, server: manager.get_prompts_from_server(server, user_api_key_auth=None), + lambda manager, server: manager.get_resources_from_server(server, user_api_key_auth=None), + lambda manager, server: manager.get_resource_templates_from_server(server, user_api_key_auth=None), + ], + ids=["tools", "prompts", "resources", "resource_templates"], + ) + async def test_listing_skips_a_stdio_server_quietly_while_stdio_is_not_enabled(self, monkeypatch, caplog, listing): + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + manager = MCPServerManager() + server = MCPServer( + server_id="stdio-quiet", + name="stdio_quiet", + transport=MCPTransport.stdio, + command="python", + args=["server.py"], + ) + + with caplog.at_level(logging.DEBUG, logger="LiteLLM"): + items = await listing(manager, server) + + assert items == [] + assert any("stdio_quiet" in r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG) + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + async def test_calling_a_tool_on_a_stdio_server_names_the_flag_while_stdio_is_not_enabled(self, monkeypatch): + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + manager = MCPServerManager() + server = MCPServer( + server_id="stdio-call", + name="stdio_call", + alias="stdio_call", + transport=MCPTransport.stdio, + command="python", + args=["server.py"], + ) + manager.registry[server.server_id] = server + + with pytest.raises(HTTPException) as exc_info: + manager._resolve_mcp_server_for_tool_call(server_name="stdio_call", name="echo") + + assert exc_info.value.status_code == 403 + assert "LITELLM_ENABLE_MCP_STDIO=true" in str(exc_info.value.detail) + + async def test_calling_an_unknown_tool_on_an_enabled_stdio_server_is_still_not_found(self, monkeypatch): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") + manager = MCPServerManager() + server = MCPServer( + server_id="stdio-call", + name="stdio_call", + alias="stdio_call", + transport=MCPTransport.stdio, + command="python", + args=["server.py"], + ) + manager.registry[server.server_id] = server + + with pytest.raises(ValueError, match="Tool echo not found"): + manager._resolve_mcp_server_for_tool_call(server_name="stdio_call", name="echo") + + @pytest.mark.parametrize("flag, routed", [(None, True), ("true", False)]) + async def test_a_prefixed_tool_name_routes_to_its_blocked_stdio_server(self, monkeypatch, flag, routed): + if flag is None: + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + else: + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", flag) + manager = MCPServerManager() + server = MCPServer( + server_id="stdio-route", + name="stdio_route", + alias="stdio_route", + transport=MCPTransport.stdio, + command="python", + args=["server.py"], + ) + manager.registry[server.server_id] = server + + resolved = manager._get_mcp_server_from_tool_name("stdio_route-echo") + + assert (resolved is server) is routed + + async def test_health_check_reports_a_stdio_server_unhealthy_with_the_flag_to_set(self, monkeypatch): + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + manager = MCPServerManager() + server = MCPServer( + server_id="stdio-health", + name="stdio_health", + transport=MCPTransport.stdio, + command="python", + args=["server.py"], + ) + manager.registry[server.server_id] = server + + result = await manager.health_check_server(server.server_id) + + assert result.status == "unhealthy" + assert "LITELLM_ENABLE_MCP_STDIO=true" in (result.health_check_error or "") + + async def test_a_config_stdio_server_stays_registered_and_warns_while_stdio_is_not_enabled( + self, monkeypatch, config_only_mcp_manager_factory, caplog + ): + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + manager = config_only_mcp_manager_factory() + config = {"local_tools": {"transport": MCPTransport.stdio, "command": "python", "args": ["server.py"]}} + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await manager.load_servers_from_config(config) + + assert [s.server_name for s in manager.config_mcp_servers.values()] == ["local_tools"] + warnings = [m for m in caplog.messages if "local_tools" in m] + assert len(warnings) == 1 + assert "LITELLM_ENABLE_MCP_STDIO=true" in warnings[0] + + async def test_a_config_stdio_server_loads_without_a_warning_once_stdio_is_enabled( + self, monkeypatch, config_only_mcp_manager_factory, caplog + ): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") + manager = config_only_mcp_manager_factory() + config = {"local_tools": {"transport": MCPTransport.stdio, "command": "python", "args": ["server.py"]}} + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await manager.load_servers_from_config(config) + + assert [s.server_name for s in manager.config_mcp_servers.values()] == ["local_tools"] + assert not [m for m in caplog.messages if "LITELLM_ENABLE_MCP_STDIO" in m] + + async def test_a_db_stdio_server_stays_registered_and_warns_while_stdio_is_not_enabled(self, monkeypatch, caplog): + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + manager = MCPServerManager() + row = LiteLLM_MCPServerTable( + server_id="db-stdio", + alias="db_stdio", + transport=MCPTransport.stdio, + command="python", + args=["server.py"], + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await manager.add_server(row) + await manager.update_server(row) + await manager.update_server(row) + + assert "db-stdio" in manager.registry + assert sum("db_stdio" in m and "LITELLM_ENABLE_MCP_STDIO=true" in m for m in caplog.messages) == 1 + def test_build_stdio_env_only_accepts_x_prefixed_placeholders(self): """Ensure only ${X-*} placeholders are substituted from headers.""" manager = MCPServerManager() @@ -9876,7 +10045,8 @@ async def test_static_token_missing_rejects_before_connecting(self): assert exc.value.status_code == 500 assert "credential" in str(exc.value.detail) - async def test_stdio_migrated_auth_type_still_defers_to_v1(self): + async def test_stdio_migrated_auth_type_still_defers_to_v1(self, monkeypatch): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") client = await MCPServerManager()._create_mcp_client( MCPServer( server_id="stdio-graft", @@ -13563,7 +13733,10 @@ async def fetch(self, user_id: str, server_id: str) -> OAuthToken | None: @pytest.mark.asyncio @pytest.mark.parametrize("transport", ["http", "stdio"]) -async def test_debug_reports_legacy_signing_and_non_http_transport(_mcp_request_ctx, transport: Literal["http", "stdio"]) -> None: +async def test_debug_reports_legacy_signing_and_non_http_transport( + _mcp_request_ctx, monkeypatch, transport: Literal["http", "stdio"] +) -> None: + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") from litellm.proxy._experimental.mcp_server.mcp_context import active_mcp_request_ctx_var from starlette.requests import Request @@ -14991,6 +15164,53 @@ async def build_from_table(table, **_kwargs): assert "'shared'" in shared_warnings[0] +@pytest.mark.asyncio +@pytest.mark.parametrize( + "flag,transports,expected_warnings", + [ + (None, ["stdio", "stdio", "stdio"], 1), + (None, ["http", "stdio", "stdio"], 1), + ("true", ["stdio", "stdio", "stdio"], 0), + ], +) +async def test_reload_warns_once_about_a_blocked_stdio_row_that_is_rebuilt_every_time( + monkeypatch, caplog, flag, transports, expected_warnings +): + if flag is None: + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + else: + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", flag) + manager = MCPServerManager() + repository = MagicMock() + + async def build_from_table(table, **_kwargs): + return MCPServer(server_id=table.server_id, name=table.server_name, transport=table.transport) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPServerRepository", + return_value=repository, + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=MagicMock(), + ), + patch.object(manager, "build_mcp_server_from_table", new=build_from_table), + patch.object(manager, "_maybe_register_openapi_tools", new=AsyncMock()), + patch.object(manager, "_prime_oauth_metadata_discovery_for_servers"), + caplog.at_level(logging.WARNING, logger="LiteLLM"), + ): + for transport in transports: + row = LiteLLM_MCPServerTable( + server_id="srv-null-ts", server_name="null_ts", transport=transport, command="python", updated_at=None + ) + repository.table.find_many = AsyncMock(return_value=[MagicMock(model_dump=row.model_dump)]) + await manager.reload_servers_from_database() + + assert manager.registry["srv-null-ts"].transport == transports[-1] + assert sum("'null_ts' will not start" in m for m in caplog.messages) == expected_warnings + + @pytest.mark.asyncio @pytest.mark.parametrize("revision", ["auto", "2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"]) async def test_configured_protocol_reaches_the_upstream_client(config_only_mcp_manager_factory, revision): diff --git a/tests/unit/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/unit/proxy/_experimental/mcp_server/test_rest_endpoints.py index 759014b54c5e..680b84469d9b 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -3730,6 +3730,10 @@ async def fake_get_tools_from_server(**kwargs): class TestStdioCommandAllowlist: """Tests for MCP stdio command allowlist validation.""" + @pytest.fixture(autouse=True) + def _stdio_enabled(self, monkeypatch): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") + def test_allowed_command_passes_validation(self): """npx, uvx, python, etc. should be accepted.""" req = NewMCPServerRequest( diff --git a/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py b/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py index 64a2eb693252..34a7f78659b0 100644 --- a/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py +++ b/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py @@ -460,3 +460,23 @@ def test_ui_discovery_endpoints_is_control_plane_false_when_no_workers(): data = response.json() assert data["is_control_plane"] is False assert data["workers"] == [] + + +@pytest.mark.parametrize(("flag", "expected"), [(None, False), ("false", False), ("true", True)]) +def test_ui_config_tells_the_dashboard_whether_stdio_mcp_servers_are_enabled(monkeypatch, flag, expected): + if flag is None: + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + else: + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", flag) + app = FastAPI() + app.include_router(router) + + with ( + patch("litellm.proxy.utils.get_server_root_path", return_value="/"), + patch("litellm.proxy.utils.get_proxy_base_url", return_value=None), + patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False), + ): + response = TestClient(app).get("/.well-known/litellm-ui-config") + + assert response.status_code == 200 + assert response.json()["mcp_stdio_enabled"] is expected diff --git a/tests/unit/proxy/management_endpoints/test_mcp_connector_import.py b/tests/unit/proxy/management_endpoints/test_mcp_connector_import.py index 9b1a0fb4f981..d60cc1fbb15f 100644 --- a/tests/unit/proxy/management_endpoints/test_mcp_connector_import.py +++ b/tests/unit/proxy/management_endpoints/test_mcp_connector_import.py @@ -124,7 +124,8 @@ def test_sse_type_maps_to_sse_transport(self): assert isinstance(result, ConvertedConnector) assert result.request.transport == MCPTransport.sse - def test_stdio_connector(self): + def test_stdio_connector(self, monkeypatch): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") result = _single( { "mcpServers": { @@ -142,11 +143,18 @@ def test_stdio_connector(self): assert result.request.args == ["-y", "@example/mcp-server"] assert result.request.env == {"API_KEY": "value"} - def test_disallowed_stdio_command_returns_error(self): + def test_disallowed_stdio_command_returns_error(self, monkeypatch): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") result = _single({"mcpServers": {"evil": {"command": "rm", "args": ["-rf", "/"]}}}) assert isinstance(result, ConnectorConversionError) assert "not in the allowed commands list" in result.error + def test_stdio_connector_is_reported_as_an_error_while_stdio_is_not_enabled(self, monkeypatch): + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + result = _single({"mcpServers": {"local": {"command": "npx", "args": ["-y", "@example/mcp-server"]}}}) + assert isinstance(result, ConnectorConversionError) + assert "LITELLM_ENABLE_MCP_STDIO=true" in result.error + def test_unsupported_type_returns_error(self): result = _single({"mcpServers": {"ws": {"type": "websocket", "url": "wss://x.example"}}}) assert isinstance(result, ConnectorConversionError) diff --git a/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py index f5fc5ae24d49..88ee36a0fef9 100644 --- a/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -4881,7 +4881,8 @@ async def test_register_mcp_server_requires_team_key(self): assert "team" in str(exc_info.value.detail).lower() @pytest.mark.asyncio - async def test_register_mcp_server_rejects_stdio_transport(self): + async def test_register_mcp_server_rejects_stdio_transport(self, monkeypatch): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") # stdio servers spawn a local subprocess on the proxy host. Accepting # them from the non-admin submission endpoint would let a team member # propose a config that an admin could rubber-stamp into local code diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index 9785bdd5e324..543461d09960 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -3503,6 +3503,50 @@ def test_ProxyConfig__decrypt_and_set_db_env_variables_sets_env(monkeypatch): } +@pytest.mark.parametrize("stored_key", ["LITELLM_ENABLE_MCP_STDIO", "litellm_enable_mcp_stdio"]) +def test_ProxyConfig__decrypt_and_set_db_env_variables_cannot_enable_mcp_stdio(monkeypatch, stored_key): + monkeypatch.setattr( + "litellm.proxy.proxy_server.decrypt_value_helper", + lambda value, key, return_original_value=False: value, + ) + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + monkeypatch.delenv(stored_key, raising=False) + monkeypatch.delenv("KEY_X", raising=False) + pc = ProxyConfig() + out = pc._decrypt_and_set_db_env_variables({stored_key: "true", "KEY_X": "x"}) + assert out == {"KEY_X": "x"} + assert os.environ.get("KEY_X") == "x" + assert os.environ.get(stored_key) is None + assert os.environ.get("LITELLM_ENABLE_MCP_STDIO") is None + + +def test_ProxyConfig__decrypt_and_set_db_env_variables_warns_once_about_the_ignored_mcp_stdio_flag( + monkeypatch, caplog +): + monkeypatch.setattr( + "litellm.proxy.proxy_server.decrypt_value_helper", + lambda value, key, return_original_value=False: value, + ) + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + pc = ProxyConfig() + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + for _ in range(3): + pc._decrypt_and_set_db_env_variables({"LITELLM_ENABLE_MCP_STDIO": "true"}) + assert os.environ.get("LITELLM_ENABLE_MCP_STDIO") is None + assert sum("Ignoring LITELLM_ENABLE_MCP_STDIO stored in the database" in m for m in caplog.messages) == 1 + + +@pytest.mark.parametrize("config_key", ["LITELLM_ENABLE_MCP_STDIO", "litellm_enable_mcp_stdio"]) +def test_ProxyConfig__load_environment_variables_cannot_enable_mcp_stdio(monkeypatch, config_key): + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + monkeypatch.delenv(config_key, raising=False) + monkeypatch.delenv("KEY_X", raising=False) + ProxyConfig()._load_environment_variables({"environment_variables": {config_key: "true", "KEY_X": "x"}}) + assert os.environ.get("KEY_X") == "x" + assert os.environ.get(config_key) is None + assert os.environ.get("LITELLM_ENABLE_MCP_STDIO") is None + + def test_ProxyConfig__decrypt_and_set_db_env_variables_invalid_dict_raises(): pc = ProxyConfig() with pytest.raises(AttributeError): diff --git a/tests/unit/proxy/test__types.py b/tests/unit/proxy/test__types.py index adc3bc04bdfc..50c3eb2908c1 100644 --- a/tests/unit/proxy/test__types.py +++ b/tests/unit/proxy/test__types.py @@ -11,10 +11,12 @@ LiteLLM_AuditLogs, LiteLLM_TeamMembership, LitellmUserRoles, + NewMCPServerRequest, NewUserRequest, OrganizationMemberUpdateRequest, ResetSpendRequest, UpdateKeyRequest, + UpdateMCPServerRequest, UpdateUserRequest, UserAPIKeyAuth, ) @@ -403,3 +405,64 @@ def test_mcp_metadata_rejects_unavailable_upstream_protocol(revision): for model in (NewMCPServerRequest, UpdateMCPServerRequest): with pytest.raises(ValidationError): model.model_validate(payload) + + +MCP_SERVER_REQUESTS = (NewMCPServerRequest, UpdateMCPServerRequest) +STDIO_SERVER_FIELDS = {"server_id": "stdio-1", "transport": "stdio", "command": "python", "args": ["server.py"]} + + +@pytest.mark.parametrize("request_model", MCP_SERVER_REQUESTS) +def test_a_stdio_mcp_server_is_refused_while_stdio_is_not_enabled(monkeypatch, request_model): + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + + with pytest.raises(ValidationError, match="LITELLM_ENABLE_MCP_STDIO=true"): + request_model(**STDIO_SERVER_FIELDS) + + +@pytest.mark.parametrize("request_model", MCP_SERVER_REQUESTS) +@pytest.mark.parametrize("flag", ["true", "TRUE", " True "]) +def test_a_stdio_mcp_server_is_accepted_once_stdio_is_enabled(monkeypatch, request_model, flag): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", flag) + + assert request_model(**STDIO_SERVER_FIELDS).command == "python" + + +@pytest.mark.parametrize("request_model", MCP_SERVER_REQUESTS) +@pytest.mark.parametrize("flag", ["false", "1", "yes", ""]) +def test_only_an_explicit_true_enables_stdio_mcp_servers(monkeypatch, request_model, flag): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", flag) + + with pytest.raises(ValidationError, match="LITELLM_ENABLE_MCP_STDIO=true"): + request_model(**STDIO_SERVER_FIELDS) + + +@pytest.mark.parametrize("request_model", MCP_SERVER_REQUESTS) +def test_a_stdio_command_outside_the_allowlist_is_refused_even_when_stdio_is_enabled(monkeypatch, request_model): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") + + with pytest.raises(ValidationError, match="not in the allowed commands list"): + request_model(**{**STDIO_SERVER_FIELDS, "command": "/bin/sh"}) + + +@pytest.mark.parametrize("request_model", MCP_SERVER_REQUESTS) +@pytest.mark.parametrize("missing", ["command", "args"]) +def test_an_enabled_stdio_mcp_server_still_needs_a_command_and_args(monkeypatch, request_model, missing): + monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true") + + with pytest.raises(ValidationError, match=f"{missing} is required for stdio transport"): + request_model(**{k: v for k, v in STDIO_SERVER_FIELDS.items() if k != missing}) + + +@pytest.mark.parametrize("request_model", MCP_SERVER_REQUESTS) +def test_an_http_mcp_server_is_unaffected_by_the_stdio_flag(monkeypatch, request_model): + monkeypatch.delenv("LITELLM_ENABLE_MCP_STDIO", raising=False) + + assert request_model(server_id="http-1", transport="http", url="https://mcp.example.com").url == "https://mcp.example.com" + with pytest.raises(ValidationError, match="url or spec_path is required"): + request_model(server_id="http-1", transport="http") + + +@pytest.mark.parametrize("request_model", MCP_SERVER_REQUESTS) +def test_a_non_mapping_mcp_server_payload_gets_a_validation_error(request_model): + with pytest.raises(ValidationError, match="valid dictionary"): + request_model.model_validate("not-a-server") diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx index 6ab11d1c9ead..3cf2bcdf238f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx @@ -1944,6 +1944,29 @@ describe("CreateMCPServer", () => { expect(nameInput).toHaveValue("github_mcp"); }); }); + + const sqlitePrefill = { + name: "sqlite", + title: "SQLite", + description: "Local database", + category: "Databases", + transport: "stdio", + command: "uvx", + args: ["mcp-server-sqlite"], + }; + + it("explains that a catalog stdio server cannot be added while the proxy has stdio off", async () => { + render(); + + expect(await screen.findByText("stdio is disabled on this proxy")).toBeInTheDocument(); + }); + + it("shows no stdio banner for a catalog stdio server once stdio is enabled", async () => { + render(); + + await waitFor(() => expect(getServerNameInput()).toHaveValue("sqlite")); + expect(screen.queryByText("stdio is disabled on this proxy")).not.toBeInTheDocument(); + }); }); describe("with back to discovery button", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx index f656bd2fc601..2d6e83aff9b8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx @@ -48,6 +48,7 @@ import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPConnectionStatus from "./mcp_connection_status"; import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; +import { StdioDisabledBanner, TransportSelectItems } from "./StdioAvailability"; import MCPPermissionManagement from "./MCPPermissionManagement"; import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection"; import MCPLogoSelector from "./MCPLogoSelector"; @@ -82,6 +83,7 @@ interface CreateMCPServerProps { existingServers?: MCPServer[]; prefillData?: DiscoverableMCPServer | null; onBackToDiscovery?: () => void; + stdioEnabled?: boolean; } const payloadErrorMessage = (result: Exclude): string => { @@ -113,6 +115,7 @@ const CreateMCPServer: React.FC = ({ existingServers, prefillData, onBackToDiscovery, + stdioEnabled = true, }) => { const form = useForm({ mode: "onChange", defaultValues: CREATE_DEFAULTS }); const registry = useMountRegistry(); @@ -750,11 +753,7 @@ const CreateMCPServer: React.FC = ({ - {TRANSPORT_ITEMS.map((item) => ( - - {item.label} - - ))} + )} @@ -917,6 +916,7 @@ const CreateMCPServer: React.FC = ({ {transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && } {/* Stdio Configuration - only show for stdio transport */} + {transportType === "stdio" && !stdioEnabled && } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx index d298d9d81454..532424a3d5ba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx @@ -138,3 +138,36 @@ describe("MCPServerCard network access", () => { expect(screen.queryByText(/^Hub:/)).not.toBeInTheDocument(); }); }); + +describe("MCPServerCard stdio availability", () => { + const stdioServer = { transport: "stdio", url: undefined, command: "python", args: ["server.py"], auth_type: "none" }; + + it("flags a stdio server with how to enable stdio when the proxy has it off", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover(screen.getByText("stdio disabled")); + + expect( + await screen.findByText( + "stdio MCP servers are disabled on this proxy. Set LITELLM_ENABLE_MCP_STDIO=true on the proxy and restart to enable them", + ), + ).toBeInTheDocument(); + }); + + it("does not flag a stdio server when the proxy has stdio on", () => { + render(); + + expect(screen.getByText("STDIO")).toBeInTheDocument(); + expect(screen.queryByText("stdio disabled")).not.toBeInTheDocument(); + }); + + it("does not flag a non-stdio server when the proxy has stdio off", () => { + render(); + + expect(screen.getByText("HTTP")).toBeInTheDocument(); + expect(screen.queryByText("stdio disabled")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx index bb153f946653..a9bb23343728 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx @@ -14,6 +14,7 @@ import { cn } from "@/lib/cva.config"; import { AUTH_TYPE, MCP_REACHABLE_DESCRIPTION, type MCPServer } from "@/components/mcp_tools/types"; import { Logo } from "@/components/molecules/logo/Logo"; import { getMaskedAndFullUrl, getMCPNetworkAccess } from "./utils"; +import { STDIO_DISABLED_MESSAGE } from "./StdioAvailability"; interface MCPServerCardProps { server: MCPServer; @@ -29,6 +30,7 @@ interface MCPServerCardProps { onByokConnect?: () => void; onOpenFillFields?: () => void; onDelete?: () => void; + stdioEnabled?: boolean; } const HEALTH_TONE: Record = { @@ -52,6 +54,7 @@ const MCPServerCard: FC = ({ onByokConnect, onOpenFillFields, onDelete, + stdioEnabled = true, }) => { const alias = server.alias || server.server_name || ""; const name = server.server_name || alias || server.server_id; @@ -220,6 +223,19 @@ const MCPServerCard: FC = ({ /> {displayTransport.toUpperCase()} {authType} + {transport === "stdio" && !stdioEnabled && ( + + + + stdio disabled + + } + /> + {STDIO_DISABLED_MESSAGE} + + )} {oauthFlowUnset && ( + + + + + + + , + ); +} + +const option = (name: RegExp) => screen.getByRole("option", { name }); + +describe("TransportSelectItems", () => { + it("greys out only the stdio option and explains how to enable it when stdio is off", async () => { + const user = userEvent.setup(); + openTransportSelect(false); + + expect(option(/Standard Input\/Output \(stdio\)/)).toHaveAttribute("data-disabled"); + expect(option(/Streamable HTTP/)).not.toHaveAttribute("data-disabled"); + expect(option(/Server-Sent Events/)).not.toHaveAttribute("data-disabled"); + expect(option(/OpenAPI Spec/)).not.toHaveAttribute("data-disabled"); + + await user.hover(screen.getByLabelText("question-circle")); + + expect(await screen.findByText(STDIO_DISABLED_MESSAGE)).toBeInTheDocument(); + }); + + it("offers stdio like any other transport when stdio is on", () => { + openTransportSelect(true); + + expect(option(/Standard Input\/Output \(stdio\)/)).not.toHaveAttribute("data-disabled"); + expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument(); + }); +}); + +describe("useMcpStdioEnabled", () => { + afterEach(() => { + switchToWorkerUrl(null); + vi.restoreAllMocks(); + }); + + const renderWithConfig = (config: object) => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => new Response(JSON.stringify(config), { status: 200 })); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useMcpStdioEnabled(), { wrapper }); + const settled = () => + waitFor(() => + expect( + client + .getQueryCache() + .getAll() + .map((query) => query.state.status), + ).toEqual(["success"]), + ); + return { result, fetchSpy, settled }; + }; + + it("reads the flag from the worker the dashboard is managing", async () => { + switchToWorkerUrl("http://worker-b.example:4000"); + const { result, fetchSpy, settled } = renderWithConfig({ mcp_stdio_enabled: false }); + + await settled(); + expect(result.current).toBe(false); + expect(fetchSpy.mock.calls.map(([request]) => (request as Request).url)).toEqual([ + "http://worker-b.example:4000/.well-known/litellm-ui-config", + ]); + }); + + it.each([ + [{ mcp_stdio_enabled: false }, false], + [{ mcp_stdio_enabled: true }, true], + [{}, true], + ])("treats %o as stdio enabled=%s", async (config, enabled) => { + const { result, settled } = renderWithConfig(config); + + await settled(); + expect(result.current).toBe(enabled); + }); + + it("keeps stdio available while the proxy has not answered yet", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(() => new Promise(() => {})); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useMcpStdioEnabled(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }); + + await waitFor(() => expect(fetchSpy).toHaveBeenCalled()); + expect(result.current).toBe(true); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioAvailability.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioAvailability.tsx new file mode 100644 index 000000000000..960f0dbbe5dd --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioAvailability.tsx @@ -0,0 +1,37 @@ +import { type FC } from "react"; +import { TriangleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { SelectItem } from "@/components/ui/select"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import { TRANSPORT, TRANSPORT_ITEMS } from "@/components/mcp_tools/types"; +import { $api } from "@/lib/http/api"; + +export const STDIO_DISABLED_MESSAGE = + "stdio MCP servers are disabled on this proxy. Set LITELLM_ENABLE_MCP_STDIO=true on the proxy and restart to enable them"; + +export const useMcpStdioEnabled = (): boolean => + $api.useQuery("get", "/.well-known/litellm-ui-config").data?.mcp_stdio_enabled !== false; + +export const TransportSelectItems: FC<{ stdioEnabled: boolean }> = ({ stdioEnabled }) => ( + <> + {TRANSPORT_ITEMS.map((item) => { + const disabled = item.value === TRANSPORT.STDIO && !stdioEnabled; + return ( + + {item.label} + {disabled && } + + ); + })} + +); + +export const StdioDisabledBanner: FC = () => ( + + + stdio is disabled on this proxy + + {STDIO_DISABLED_MESSAGE}. Until then this server cannot start or be saved as stdio. + + +); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx index 5aec78ba9265..f79dd1785711 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx @@ -242,6 +242,56 @@ describe("MCPServerEdit (stdio)", () => { }); }); +describe("MCPServerEdit (stdio disabled on the proxy)", () => { + const stdioServer = { + server_id: "server-1", + server_name: "TestServer", + alias: "test", + transport: "stdio", + url: null, + auth_type: "none", + command: "npx", + args: ["-y", "@circleci/mcp-server-circleci"], + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + mcp_access_groups: [], + }; + const renderEdit = (mcpServer: object, stdioEnabled: boolean) => + render( + ["mcpServer"]} + accessToken={null} + onCancel={vi.fn()} + onSuccess={vi.fn()} + availableAccessGroups={[]} + stdioEnabled={stdioEnabled} + />, + ); + + it("explains why an existing stdio server cannot run or be saved as stdio", () => { + renderEdit(stdioServer, false); + + expect(screen.getByText("stdio is disabled on this proxy")).toBeInTheDocument(); + expect(screen.getByText(/Set LITELLM_ENABLE_MCP_STDIO=true on the proxy and restart/)).toBeInTheDocument(); + }); + + it("shows no banner for a stdio server once stdio is enabled", () => { + renderEdit(stdioServer, true); + + expect(screen.getByLabelText("Command")).toBeInTheDocument(); + expect(screen.queryByText("stdio is disabled on this proxy")).not.toBeInTheDocument(); + }); + + it("shows no banner for a non-stdio server while stdio is disabled", () => { + renderEdit({ ...stdioServer, transport: "http", url: "https://mcp.example.com/mcp" }, false); + + expect(screen.getByRole("tab", { name: "Server Configuration" })).toBeInTheDocument(); + expect(screen.queryByText("stdio is disabled on this proxy")).not.toBeInTheDocument(); + }); +}); + describe("MCPServerEdit (delegate auth)", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx index d45909445e96..3894cb6cd0b9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx @@ -44,6 +44,7 @@ import TruePassthroughWarning from "./TruePassthroughWarning"; import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection"; import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; +import { StdioDisabledBanner, TransportSelectItems } from "./StdioAvailability"; import TokenExchangeFormFields from "./TokenExchangeFormFields"; import IdJagFormFields from "./IdJagFormFields"; import OAuthFormFields from "./OAuthFormFields"; @@ -90,6 +91,7 @@ interface MCPServerEditProps { onSuccess: (server: MCPServer) => void; availableAccessGroups: string[]; existingServers?: MCPServer[]; + stdioEnabled?: boolean; } const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.TOKEN, AUTH_TYPE.BASIC]; @@ -103,6 +105,7 @@ const MCPServerEdit: React.FC = ({ onSuccess, availableAccessGroups, existingServers, + stdioEnabled = true, }) => { const initialStaticHeaders = React.useMemo(() => { if (!mcpServer.static_headers) { @@ -822,6 +825,7 @@ const MCPServerEdit: React.FC = ({ void submitForm(); }} > + {isStdioTransport && !stdioEnabled && } = ({ - {TRANSPORT_ITEMS.map((item) => ( - - {item.label} - - ))} + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx index 7e1eda9143f6..784bd61382b7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx @@ -222,6 +222,31 @@ describe("MCPServerView", () => { expect(screen.queryByText("edit form")).not.toBeInTheDocument(); }); + it.each([ + { transport: "stdio", stdioEnabled: false, shown: true }, + { transport: "stdio", stdioEnabled: true, shown: false }, + { transport: "http", stdioEnabled: false, shown: false }, + ])( + "explains why a $transport server is inert when stdioEnabled=$stdioEnabled", + ({ transport, stdioEnabled, shown }) => { + renderView({ transport }, { stdioEnabled }); + + expect(screen.getByText("srv-1")).toBeInTheDocument(); + expect(screen.queryByText("stdio is disabled on this proxy") !== null).toBe(shown); + }, + ); + + it("leaves the stdio warning to the edit form once editing starts", async () => { + renderView({ transport: "stdio" }, { stdioEnabled: false }); + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + expect(screen.getByText("stdio is disabled on this proxy")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Edit Settings" })); + + expect(screen.getByText("edit form")).toBeInTheDocument(); + expect(screen.queryByText("stdio is disabled on this proxy")).not.toBeInTheDocument(); + }); + it("opens on the tab named by initialTabIndex", async () => { renderView({}, { initialTabIndex: 1 }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx index 278a98a2fa6f..02d961a818de 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types"; +import { MCPServer, TRANSPORT, handleTransport, handleAuth } from "@/components/mcp_tools/types"; // TODO: Move Tools viewer from index file import { MCPToolsViewer } from "."; import MCPServerEdit, { EDIT_OAUTH_UI_STATE_KEY } from "./mcp_server_edit"; @@ -15,6 +15,7 @@ import { MCPServerUserCredentialsPanel } from "./MCPServerUserCredentialsPanel"; import { getSecureItem } from "@/utils/secureStorage"; import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles"; import MCPServerCostDisplay from "./mcp_server_cost_display"; +import { StdioDisabledBanner } from "./StdioAvailability"; import { getMaskedAndFullUrl, getMCPNetworkAccess } from "./utils"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import { CheckIcon, CopyIcon } from "lucide-react"; @@ -31,6 +32,7 @@ interface MCPServerViewProps { availableAccessGroups: string[]; existingServers?: MCPServer[]; initialTabIndex?: number; + stdioEnabled?: boolean; } // True when this render is the return from the edit-settings OAuth redirect for this @@ -63,6 +65,7 @@ export const MCPServerView: React.FC = ({ availableAccessGroups, existingServers, initialTabIndex = 0, + stdioEnabled = true, }) => { // Open the editing Settings tab on first render when returning from the edit OAuth // redirect, so the "token fetched" feedback shows where the user left off (Settings=2). @@ -74,6 +77,8 @@ export const MCPServerView: React.FC = ({ const networkAccess = getMCPNetworkAccess(mcpServer); const [copiedStates, setCopiedStates] = useState>({}); const [selectedTabIndex, setSelectedTabIndex] = useState(returningFromEditOAuth ? 2 : initialTabIndex); + const editFormShowsStdioBanner = selectedTabIndex === 2 && editing && canEdit; + const showStdioBanner = mcpServer.transport === TRANSPORT.STDIO && !stdioEnabled && !editFormShowsStdioBanner; const canViewUserCredentials = userRole !== null && isProxyAdminTierRole(userRole); const canRevokeUserCredentials = userRole !== null && isProxyAdminRole(userRole) && !isViewOnly; @@ -144,6 +149,8 @@ export const MCPServerView: React.FC = ({ {mcpServer.description &&

{mcpServer.description}

} + {showStdioBanner && } + setSelectedTabIndex(Number(v))}> @@ -253,6 +260,7 @@ export const MCPServerView: React.FC = ({ onSuccess={handleSuccess} availableAccessGroups={availableAccessGroups} existingServers={existingServers} + stdioEnabled={stdioEnabled} /> ) : (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx index 9a3ff0cc6cbe..2f4fcfd5f69c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx @@ -20,8 +20,12 @@ vi.mock("@/components/networking", () => ({ listMCPUserEnvVarStatus: vi.fn().mockResolvedValue([]), fetchMCPGatewaySessions: vi.fn(), terminateMCPGatewaySessions: vi.fn(), + getUiConfig: vi.fn().mockResolvedValue({}), })); +const stubUiConfig = (config: object) => + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify(config), { status: 200 })); + const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -135,6 +139,7 @@ describe("MCPServers", () => { beforeEach(() => { vi.clearAllMocks(); + stubUiConfig({}); }); it("should render the MCPServers component with title", async () => { @@ -299,6 +304,90 @@ describe("MCPServers", () => { expect(screen.getByText("No servers match the current filters or search.")).toBeVisible(); }); + it.each([ + [false, true], + [true, false], + ])("marks stdio servers as disabled only when the proxy reports stdio off (enabled=%s)", async (enabled, flagged) => { + stubUiConfig({ mcp_stdio_enabled: enabled }); + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { + server_id: "stdio-1", + server_name: "local_tools", + alias: "local_tools", + transport: "stdio", + command: "python", + args: ["server.py"], + created_by: "user", + updated_by: "user", + } as MCPServer, + ]); + vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue([]); + + render( + + + , + ); + + const grid = await screen.findByTestId("mcp-servers-grid"); + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + await waitFor(() => expect(within(grid).queryByText("stdio disabled") !== null).toBe(flagged)); + expect(within(grid).getByText("STDIO")).toBeInTheDocument(); + }); + + const stdioServer = { + server_id: "stdio-1", + server_name: "local_tools", + alias: "local_tools", + transport: "stdio", + command: "python", + args: ["server.py"], + created_by: "user", + updated_by: "user", + } as MCPServer; + + it("greys out stdio in the create form when the proxy reports stdio off", async () => { + stubUiConfig({ mcp_stdio_enabled: false }); + vi.mocked(networking.fetchMCPServers).mockResolvedValue([]); + const user = userEvent.setup(); + render( + + + , + ); + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + + await user.click(await screen.findByRole("button", { name: "+ Submit MCP Server" })); + await user.click(await screen.findByRole("combobox", { name: /Transport Type/ })); + + expect(await screen.findByRole("option", { name: /Standard Input\/Output \(stdio\)/ })).toHaveAttribute( + "data-disabled", + ); + expect(screen.getByRole("option", { name: /Streamable HTTP/ })).not.toHaveAttribute("data-disabled"); + + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument()); + }); + + it("explains on the edit page why an existing stdio server cannot run when the proxy reports stdio off", async () => { + stubUiConfig({ mcp_stdio_enabled: false }); + vi.mocked(networking.fetchMCPServers).mockResolvedValue([stdioServer]); + vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue([]); + const user = userEvent.setup(); + render( + + + , + ); + const grid = await screen.findByTestId("mcp-servers-grid"); + await waitFor(() => expect(within(grid).getByText("stdio disabled")).toBeInTheDocument()); + + await user.click(within(grid).getAllByText("local_tools")[0]); + await user.click(await screen.findByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("stdio is disabled on this proxy")).toBeInTheDocument(); + }); + it("should render mocked MCP servers data in the table", async () => { // Mock MCP servers data const mockServers = [ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx index 56a18e0ca4c6..13510cadf39c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx @@ -29,6 +29,7 @@ import CreateMCPServer from "./CreateMCPServer"; import ImportMCPServers from "./ImportMCPServers"; import MCPConnect from "./mcp_connect"; import MCPServerCard from "./MCPServerCard"; +import { useMcpStdioEnabled } from "./StdioAvailability"; import { MCPServerView } from "./mcp_server_view"; import type { DiscoverableMCPServer, @@ -225,6 +226,8 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID, i const [sortKey, setSortKey] = useState("created_desc"); const isInternalUser = userRole === "Internal User"; + const stdioEnabled = useMcpStdioEnabled(); + // Single bulk fetch of this user's per-server env-var status. Drives the // red "N user fields missing" footer on each card with no per-row request. const { data: envVarStatuses, refetch: refetchEnvVarStatus } = useQuery({ @@ -500,6 +503,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID, i availableAccessGroups={uniqueMcpAccessGroups} existingServers={mcpServers} prefillData={prefillData} + stdioEnabled={stdioEnabled} onBackToDiscovery={() => { setModalVisible(false); setPrefillData(null); @@ -614,6 +618,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID, i availableAccessGroups={uniqueMcpAccessGroups} existingServers={mcpServers} initialTabIndex={selectedServerId === toolsTabServerId ? 1 : 0} + stdioEnabled={stdioEnabled} /> ) : (
@@ -752,6 +757,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID, i onByokConnect={server.is_byok ? () => setByokModalServer(server) : undefined} onOpenFillFields={() => setEnvVarsModalServer(server)} onDelete={isAdminRole(userRole) ? () => handleDelete(server.server_id) : undefined} + stdioEnabled={stdioEnabled} /> ))}
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 97d1782b08ca..a8fcd872a4ab 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -370,6 +370,7 @@ export interface LiteLLMWellKnownUiConfig { hide_default_credentials_hint?: boolean; is_control_plane?: boolean; workers?: WorkerInfo[]; + mcp_stdio_enabled?: boolean; } export interface CredentialsResponse { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ebc6d0e70ccf..b47689d18518 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -46110,6 +46110,11 @@ export interface components { * @default false */ is_control_plane: boolean; + /** + * Mcp Stdio Enabled + * @default false + */ + mcp_stdio_enabled: boolean; /** Proxy Base Url */ proxy_base_url: string | null; /** Server Root Path */