diff --git a/litellm/proxy/_experimental/mcp_server/client_allowlist.py b/litellm/proxy/_experimental/mcp_server/client_allowlist.py index 57343c39565..4eefdd21c92 100644 --- a/litellm/proxy/_experimental/mcp_server/client_allowlist.py +++ b/litellm/proxy/_experimental/mcp_server/client_allowlist.py @@ -6,9 +6,10 @@ name is client-supplied, so this is a policy control and not a security boundary import json from dataclasses import dataclass -from typing import Final +from typing import Final, Literal from pydantic import TypeAdapter, ValidationError +from typing_extensions import ReadOnly, TypedDict from litellm._logging import verbose_logger @@ -17,6 +18,11 @@ MCP_ALLOWED_CLIENTS_SETTING: Final = "mcp_allowed_clients" _ALLOWED_CLIENTS_ADAPTER: Final = TypeAdapter(list[str]) +class MCPClientForbiddenBody(TypedDict): + error: ReadOnly[Literal["Forbidden"]] + details: ReadOnly[str] + + @dataclass(frozen=True, slots=True) class MCPClientRejection: client_name: str | None @@ -30,6 +36,11 @@ class MCPClientRejection: ) return f"MCP client '{self.client_name}' is not listed in this gateway's {MCP_ALLOWED_CLIENTS_SETTING}." + @property + def response_body(self) -> MCPClientForbiddenBody: + body: Final[MCPClientForbiddenBody] = {"error": "Forbidden", "details": self.details} + return body + def parse_allowed_mcp_clients(raw_setting: object) -> frozenset[str] | None: """None when the setting is absent (not enforced). A malformed setting admits nobody.""" diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index cca867d4d2a..eea57218865 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -3839,16 +3839,17 @@ if MCP_AVAILABLE: ) forbidden: Final = JSONResponse( status_code=403, - content={"error": "Forbidden", "details": rejection.details}, + content=rejection.response_body, ) await forbidden(scope, receive, send) return True - def _replay_consumed_messages(consumed_messages: list[Message], receive: Receive) -> Receive: + def _replay_consumed_messages(consumed_messages: Sequence[Message], receive: Receive) -> Receive: + pending: Final = iter(consumed_messages) + async def wrapped_receive() -> Message: - if consumed_messages: - return consumed_messages.pop(0) - return await receive() + replayed: Final = next(pending, None) + return replayed if replayed is not None else await receive() return wrapped_receive @@ -4825,7 +4826,7 @@ if MCP_AVAILABLE: await asyncio.sleep(0.1) sse_consumed_messages, sse_body = ( - await _read_request_body_for_routing(receive) if scope.get("method") == "POST" else ([], b"") + await _read_request_body_for_routing(receive) if scope.get("method") == "POST" else ((), b"") ) if _is_initialize_request(sse_body) and await _reject_initialize_from_disallowed_client( scope, receive, send, sse_body, _sse_client_ip diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 8550226e19d..71e0deeb722 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -1999,7 +1999,7 @@ async def test_mcp_routing_initialize_to_stateful_no_session_to_stateless( stateless_handle: Final = AsyncMock(side_effect=handle_request) stateful_handle: Final = AsyncMock(side_effect=handle_request) with ( - patch( + patch( # test-quality-ok: the ASGI handler resolves auth through a module-level function; no injection seam "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", new_callable=AsyncMock, return_value=( @@ -2012,11 +2012,11 @@ async def test_mcp_routing_initialize_to_stateful_no_session_to_stateless( ), ), patch("litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True), - patch( + patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable "litellm.proxy._experimental.mcp_server.server.session_manager_stateless", SimpleNamespace(handle_request=stateless_handle), ), - patch( + patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable "litellm.proxy._experimental.mcp_server.server.session_manager_stateful", SimpleNamespace(handle_request=stateful_handle), ), @@ -2069,13 +2069,17 @@ def _forbidden_client_response(send: AsyncMock) -> tuple[int, dict[str, str]]: def _client_allowlist_patches(allowed_clients: object): settings: Final = {} if allowed_clients is None else {"mcp_allowed_clients": allowed_clients} with ( - patch( + patch( # test-quality-ok: the ASGI handler resolves auth through a module-level function; no injection seam "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", new_callable=AsyncMock, return_value=(UserAPIKeyAuth(user_id="allowlist-user"), None, None, None, None, {}), ), - patch("litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True), - patch("litellm.proxy.proxy_server.general_settings", settings), + patch( # test-quality-ok: module flag guarding lazy session-manager startup; no injection seam + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True + ), + patch( # test-quality-ok: the allowlist is read off this module global; no injection seam + "litellm.proxy.proxy_server.general_settings", settings + ), ): yield @@ -2111,15 +2115,17 @@ async def test_streamable_http_rejects_initialize_from_unlisted_client_before_se with ( _client_allowlist_patches(["antigravity-cli"]), - patch( + patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable "litellm.proxy._experimental.mcp_server.server.session_manager_stateful", SimpleNamespace(handle_request=stateful_handle), ), - patch( + patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable "litellm.proxy._experimental.mcp_server.server.session_manager_stateless", SimpleNamespace(handle_request=stateless_handle), ), - patch("litellm.proxy._experimental.mcp_server.server._enforce_stateful_session_cap_for_owner", session_cap), + patch( # test-quality-ok: module-level cap check; asserting it is never reached is the point + "litellm.proxy._experimental.mcp_server.server._enforce_stateful_session_cap_for_owner", session_cap + ), ): await mcp_module.handle_streamable_http_mcp(scope, receive, send) @@ -2164,11 +2170,11 @@ async def test_streamable_http_admits_listed_or_unrestricted_initialize_and_repl with ( _client_allowlist_patches(allowed_clients), - patch( + patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable "litellm.proxy._experimental.mcp_server.server.session_manager_stateful", SimpleNamespace(handle_request=stateful_handle), ), - patch( + patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable "litellm.proxy._experimental.mcp_server.server.session_manager_stateless", SimpleNamespace(handle_request=stateless_handle), ), @@ -2196,7 +2202,7 @@ async def test_streamable_http_empty_or_malformed_allowlist_admits_nobody(allowe with ( _client_allowlist_patches(allowed_clients), - patch( + patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable "litellm.proxy._experimental.mcp_server.server.session_manager_stateful", SimpleNamespace(handle_request=stateful_handle), ), @@ -2225,7 +2231,7 @@ async def test_streamable_http_allowlist_only_inspects_initialize_requests() -> with ( _client_allowlist_patches(["antigravity-cli"]), - patch( + patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable "litellm.proxy._experimental.mcp_server.server.session_manager_stateless", SimpleNamespace(handle_request=AsyncMock(side_effect=handle_request)), ), @@ -2247,7 +2253,12 @@ async def test_sse_endpoint_applies_the_same_client_allowlist(request_body: byte from litellm.proxy._experimental.mcp_server import server as mcp_module scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp/sse", "headers": []} - receive: Final = AsyncMock(side_effect=[{"type": "http.request", "body": request_body, "more_body": False}]) + receive: Final = AsyncMock( + side_effect=[ + {"type": "http.request", "body": request_body, "more_body": False}, + {"type": "http.request", "body": b"not-the-replayed-initialize", "more_body": False}, + ] + ) send: Final = AsyncMock() downstream_bodies: Final[list[bytes]] = [] @@ -2256,15 +2267,17 @@ async def test_sse_endpoint_applies_the_same_client_allowlist(request_body: byte with ( _client_allowlist_patches(["antigravity-cli"]), - patch( + patch( # test-quality-ok: module-level pre-auth probe unrelated to the allowlist under test; no injection seam "litellm.proxy._experimental.mcp_server.server._raise_preemptive_401_for_unauthenticated_servers", new_callable=AsyncMock, ), - patch( + patch( # test-quality-ok: module-level upstream auth probe unrelated to the allowlist under test; no injection seam "litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth", new_callable=AsyncMock, ), - patch.object(mcp_module.sse_session_manager, "handle_request", side_effect=handle_request), + patch.object( # test-quality-ok: SSE manager is a module singleton; the downstream call is the observable + mcp_module.sse_session_manager, "handle_request", side_effect=handle_request + ), ): await mcp_module.handle_sse_mcp(scope, receive, send) @@ -2326,7 +2339,7 @@ async def test_mcp_routing_chunked_initialize_to_stateful(): stateful_called.append(1) with ( - patch( + patch( # test-quality-ok: the ASGI handler resolves auth through a module-level function; no injection seam "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", new_callable=AsyncMock, return_value=(MagicMock(), None, ["progress_test"], None, None, None), @@ -2438,7 +2451,7 @@ async def test_mcp_routing_caps_body_peek_for_oversized_chunked_body(): raise AssertionError("non-initialize POST should not reach stateful manager") with ( - patch( + patch( # test-quality-ok: the ASGI handler resolves auth through a module-level function; no injection seam "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", new_callable=AsyncMock, return_value=(MagicMock(), None, ["progress_test"], None, None, None), @@ -2587,7 +2600,7 @@ async def test_mcp_routing_initialize_rejected_when_owner_at_session_cap(): stateful_called.append(1) with ( - patch( + patch( # test-quality-ok: the ASGI handler resolves auth through a module-level function; no injection seam "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", new_callable=AsyncMock, return_value=(MagicMock(), None, ["progress_test"], None, None, None), @@ -2679,7 +2692,7 @@ async def test_stateful_mcp_requests_refresh_session_auth_context(): captured_context = callback_context.run(get_auth_context) with ( - patch( + patch( # test-quality-ok: the ASGI handler resolves auth through a module-level function; no injection seam "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", new_callable=AsyncMock, return_value=( @@ -3215,7 +3228,7 @@ async def test_stateful_mcp_session_owner_mismatch_returns_403(): handle_request_mock = AsyncMock() with ( - patch( + patch( # test-quality-ok: the ASGI handler resolves auth through a module-level function; no injection seam "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", new_callable=AsyncMock, return_value=(intruder_auth, None, None, None, None, None), diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 4a0f543dc38..25a1f83c61f 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -7428,18 +7428,10 @@ async def test_update_general_settings_keeps_yaml_pass_through_endpoints_next_to request.query_params = {} return request - settings: Final = patch( - "litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [yaml_endpoint]} - ) # test-quality-ok: the method reads this module global; no injection seam - yaml_endpoints: Final = patch( - "litellm.proxy.proxy_server.config_passthrough_endpoints", [yaml_endpoint] - ) # test-quality-ok: module global holding the YAML endpoints the fix merges in - initialize: Final = patch( - "litellm.proxy.proxy_server.initialize_pass_through_endpoints", AsyncMock() - ) # test-quality-ok: route registration needs the FastAPI app; auth is the observable here - master_key: Final = patch( - "litellm.proxy.proxy_server.master_key", "sk-master" - ) # test-quality-ok: a set master key is what makes a missing Authorization header a 401 + settings: Final = patch("litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [yaml_endpoint]}) # test-quality-ok: the method reads this module global; no injection seam + yaml_endpoints: Final = patch("litellm.proxy.proxy_server.config_passthrough_endpoints", [yaml_endpoint]) # test-quality-ok: module global holding the YAML endpoints the fix merges in + initialize: Final = patch("litellm.proxy.proxy_server.initialize_pass_through_endpoints", AsyncMock()) # test-quality-ok: route registration needs the FastAPI app; auth is the observable here + master_key: Final = patch("litellm.proxy.proxy_server.master_key", "sk-master") # test-quality-ok: a set master key is what makes a missing Authorization header a 401 with settings, yaml_endpoints, initialize, master_key: await ProxyConfig()._update_general_settings(db_general_settings={"pass_through_endpoints": [db_endpoint]}) @@ -7487,18 +7479,10 @@ async def test_update_general_settings_db_pass_through_endpoint_overrides_yaml_e request.headers = {} request.query_params = {} - settings: Final = patch( - "litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [yaml_endpoint]} - ) # test-quality-ok: the method reads this module global; no injection seam - yaml_endpoints: Final = patch( - "litellm.proxy.proxy_server.config_passthrough_endpoints", [yaml_endpoint] - ) # test-quality-ok: module global holding the YAML endpoints the fix merges in - initialize: Final = patch( - "litellm.proxy.proxy_server.initialize_pass_through_endpoints", AsyncMock() - ) # test-quality-ok: route registration needs the FastAPI app; auth is the observable here - master_key: Final = patch( - "litellm.proxy.proxy_server.master_key", "sk-master" - ) # test-quality-ok: a set master key is what makes a missing Authorization header a 401 + settings: Final = patch("litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [yaml_endpoint]}) # test-quality-ok: the method reads this module global; no injection seam + yaml_endpoints: Final = patch("litellm.proxy.proxy_server.config_passthrough_endpoints", [yaml_endpoint]) # test-quality-ok: module global holding the YAML endpoints the fix merges in + initialize: Final = patch("litellm.proxy.proxy_server.initialize_pass_through_endpoints", AsyncMock()) # test-quality-ok: route registration needs the FastAPI app; auth is the observable here + master_key: Final = patch("litellm.proxy.proxy_server.master_key", "sk-master") # test-quality-ok: a set master key is what makes a missing Authorization header a 401 with settings, yaml_endpoints, initialize, master_key: await ProxyConfig()._update_general_settings(db_general_settings={"pass_through_endpoints": [db_endpoint]}) @@ -8613,7 +8597,9 @@ async def test_increment_spend_counters_finalizes_after_unreserved_increments(): async def assert_reservation_not_finalized_yet(**kwargs): assert budget_reservation["finalized"] is False incremented_counters.append(kwargs["counter_key"]) - return ps.PendingSpendIncrement(counter_key=kwargs["counter_key"], increment=kwargs["increment"]) + return ps.PendingSpendIncrement( + counter_key=kwargs["counter_key"], increment=kwargs["increment"] + ) import litellm.proxy.proxy_server as ps @@ -10158,15 +10144,9 @@ async def _lit6973_drive_realtime_session( side_effect=pre_call_error, return_value=({"model": "vertex_ai/gemini-live-2.5-flash"}, logging_obj) ) ws: Final = websocket if websocket is not None else _lit6973_fake_realtime_ws() - can_call = patch.object( - ps, "can_key_call_resolved_model", new=AsyncMock(side_effect=model_access_error) - ) # test-quality-ok: no HTTP boundary; fakes in-process auth to reach the exit under test - pre = patch.object( - ps.ProxyBaseLLMRequestProcessing, "common_processing_pre_call_logic", new=pre_call - ) # test-quality-ok: fakes phase-1 wiring; assertion checks observable reservation state - route = patch.object( - ps, "route_request", new=AsyncMock(return_value=fake_llm_call()) - ) # test-quality-ok: fakes the relay whose success/refusal outcome the endpoint reads off the logging object + can_call = patch.object(ps, "can_key_call_resolved_model", new=AsyncMock(side_effect=model_access_error)) # test-quality-ok: no HTTP boundary; fakes in-process auth to reach the exit under test + pre = patch.object(ps.ProxyBaseLLMRequestProcessing, "common_processing_pre_call_logic", new=pre_call) # test-quality-ok: fakes phase-1 wiring; assertion checks observable reservation state + route = patch.object(ps, "route_request", new=AsyncMock(return_value=fake_llm_call())) # test-quality-ok: fakes the relay whose success/refusal outcome the endpoint reads off the logging object with can_call, pre, route: await ps.realtime_websocket_endpoint( websocket=ws, @@ -10298,9 +10278,13 @@ async def _lit6463_drive_realtime_session_holding_a_max_parallel_slot( from litellm.proxy.utils import InternalUsageCache dual_cache: Final = DualCache() - await dual_cache.async_set_cache(key=_LIT6463_COUNTER_KEY, value={"slot-1": 1.0, "slot-2": 2.0}, local_only=True) + await dual_cache.async_set_cache( + key=_LIT6463_COUNTER_KEY, value={"slot-1": 1.0, "slot-2": 2.0}, local_only=True + ) limiter: Final = _PROXY_MaxParallelRequestsHandler_v3(internal_usage_cache=InternalUsageCache(dual_cache)) - stash: Final = RequestRateLimiterStash(parallel_slot={"slot_id": "slot-1", "counter_keys": [_LIT6463_COUNTER_KEY]}) + stash: Final = RequestRateLimiterStash( + parallel_slot={"slot_id": "slot-1", "counter_keys": [_LIT6463_COUNTER_KEY]} + ) reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []} stash_token: Final = _request_stash.set(stash) @@ -10352,7 +10336,9 @@ async def test_successful_realtime_session_leaves_the_max_parallel_slot_for_the_ limiter's integer in-memory fallback, double-decrement the counter so the key admits more sessions than max_parallel_requests allows. With the success stamp present the route leaves the slot and the stash alone.""" - dual_cache, stash = await _lit6463_drive_realtime_session_holding_a_max_parallel_slot(backend_logged_success=True) + dual_cache, stash = await _lit6463_drive_realtime_session_holding_a_max_parallel_slot( + backend_logged_success=True + ) assert await dual_cache.async_get_cache(key=_LIT6463_COUNTER_KEY, local_only=True) == { "slot-1": 1.0, @@ -10398,12 +10384,8 @@ async def test_release_or_invalidate_falls_back_to_invalidating_the_counters(): async def _record(counter_key: str) -> None: invalidated.append(counter_key) - failing_release = patch.object( - br, "release_budget_reservation", new=AsyncMock(side_effect=RuntimeError("counter store down")) - ) # test-quality-ok: forces the failure branch; assertion observes which counter key got invalidated - sink = patch.object( - ps, "_invalidate_spend_counter", new=_record - ) # test-quality-ok: fakes the counter-store sink so the invalidated key is observable + failing_release = patch.object(br, "release_budget_reservation", new=AsyncMock(side_effect=RuntimeError("counter store down"))) # test-quality-ok: forces the failure branch; assertion observes which counter key got invalidated + sink = patch.object(ps, "_invalidate_spend_counter", new=_record) # test-quality-ok: fakes the counter-store sink so the invalidated key is observable with failing_release, sink: await br.release_or_invalidate_budget_reservation(budget_reservation=reservation) @@ -10419,12 +10401,8 @@ async def test_release_or_invalidate_finalizes_even_when_the_invalidate_fallback from litellm.proxy.spend_tracking import budget_reservation as br reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []} - failing_release = patch.object( - br, "release_budget_reservation", new=AsyncMock(side_effect=RuntimeError("counter store down")) - ) # test-quality-ok: forces the fallback branch - failing_invalidate = patch.object( - br, "invalidate_budget_reservation_counters", new=AsyncMock(side_effect=RuntimeError("still down")) - ) # test-quality-ok: forces the fallback itself to fail + failing_release = patch.object(br, "release_budget_reservation", new=AsyncMock(side_effect=RuntimeError("counter store down"))) # test-quality-ok: forces the fallback branch + failing_invalidate = patch.object(br, "invalidate_budget_reservation_counters", new=AsyncMock(side_effect=RuntimeError("still down"))) # test-quality-ok: forces the fallback itself to fail with failing_release, failing_invalidate: await br.release_or_invalidate_budget_reservation(budget_reservation=reservation) @@ -13009,15 +12987,9 @@ async def test_moderations_response_carries_litellm_call_id_header(): user_api_key_dict = UserAPIKeyAuth(api_key="sk-test", spend=0.0) with ( - patch.object( - proxy_server_module, "add_litellm_data_to_request", new=passthrough_add_litellm_data - ), # test-quality-ok: the route reads this module global, no injection point - patch.object( - proxy_server_module, "route_request", new=AsyncMock(return_value=fake_llm_call()) - ), # test-quality-ok: fakes the provider call so the response headers assembled by the real route are observable - patch.object( - proxy_server_module, "proxy_logging_obj" - ) as mock_logging, # test-quality-ok: module global, no injection point + patch.object(proxy_server_module, "add_litellm_data_to_request", new=passthrough_add_litellm_data), # test-quality-ok: the route reads this module global, no injection point + patch.object(proxy_server_module, "route_request", new=AsyncMock(return_value=fake_llm_call())), # test-quality-ok: fakes the provider call so the response headers assembled by the real route are observable + patch.object(proxy_server_module, "proxy_logging_obj") as mock_logging, # test-quality-ok: module global, no injection point ): mock_logging.pre_call_hook = AsyncMock(side_effect=lambda user_api_key_dict, data, call_type: data) mock_logging.update_request_status = AsyncMock() @@ -13054,15 +13026,9 @@ async def test_moderations_failure_log_carries_the_callers_litellm_call_id(caplo verbose_proxy_logger.propagate = True try: with ( - patch.object( - proxy_server_module, "add_litellm_data_to_request", new=passthrough_add_litellm_data - ), # test-quality-ok: the route reads this module global, no injection point - patch.object( - proxy_server_module, "route_request", new=AsyncMock(side_effect=Exception("bad key")) - ), # test-quality-ok: fakes the provider failure so the real route's error log is observable - patch.object( - proxy_server_module, "proxy_logging_obj", new=fake_logging - ), # test-quality-ok: module global, no injection point + patch.object(proxy_server_module, "add_litellm_data_to_request", new=passthrough_add_litellm_data), # test-quality-ok: the route reads this module global, no injection point + patch.object(proxy_server_module, "route_request", new=AsyncMock(side_effect=Exception("bad key"))), # test-quality-ok: fakes the provider failure so the real route's error log is observable + patch.object(proxy_server_module, "proxy_logging_obj", new=fake_logging), # test-quality-ok: module global, no injection point caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"), pytest.raises(ProxyException) as raised, ): @@ -13095,9 +13061,7 @@ async def test_moderations_unparseable_body_bills_the_callers_litellm_call_id(): fake_logging.post_call_failure_hook = AsyncMock() with ( - patch.object( - proxy_server_module, "proxy_logging_obj", new=fake_logging - ), # test-quality-ok: module global, no injection point + patch.object(proxy_server_module, "proxy_logging_obj", new=fake_logging), # test-quality-ok: module global, no injection point pytest.raises(ProxyException) as raised, ): await proxy_server_module.moderations( @@ -13125,12 +13089,8 @@ async def test_moderations_already_shaped_failure_answers_with_the_callers_litel fake_logging.post_call_failure_hook = AsyncMock() with ( - patch.object( - proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc) - ), # test-quality-ok: the route reads this module global, no injection point - patch.object( - proxy_server_module, "proxy_logging_obj", new=fake_logging - ), # test-quality-ok: module global, no injection point + patch.object(proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc)), # test-quality-ok: the route reads this module global, no injection point + patch.object(proxy_server_module, "proxy_logging_obj", new=fake_logging), # test-quality-ok: module global, no injection point pytest.raises(ProxyException) as raised, ): await proxy_server_module.moderations( @@ -13165,12 +13125,8 @@ async def test_audio_speech_already_shaped_failure_answers_with_the_callers_lite fake_logging.post_call_failure_hook = AsyncMock() with ( - patch.object( - proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc) - ), # test-quality-ok: the route reads this module global, no injection point - patch.object( - proxy_server_module, "proxy_logging_obj", new=fake_logging - ), # test-quality-ok: module global, no injection point + patch.object(proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc)), # test-quality-ok: the route reads this module global, no injection point + patch.object(proxy_server_module, "proxy_logging_obj", new=fake_logging), # test-quality-ok: module global, no injection point pytest.raises(type(exc)) as raised, ): await proxy_server_module.audio_speech( @@ -13872,7 +13828,9 @@ async def test_update_general_settings_propagates_mcp_allowed_clients(db_general proxy_config = ProxyConfig() - with patch("litellm.proxy.proxy_server.general_settings", {"mcp_allowed_clients": ["claude-code"]}): + with patch( # test-quality-ok: the method writes this module global; no injection seam + "litellm.proxy.proxy_server.general_settings", {"mcp_allowed_clients": ["claude-code"]} + ): await proxy_config._update_general_settings(db_general_settings=db_general_settings) import litellm.proxy.proxy_server as ps @@ -13887,7 +13845,9 @@ async def test_update_general_settings_keeps_yaml_mcp_allowed_clients(): proxy_config = ProxyConfig() proxy_config._yaml_general_settings_keys = {"mcp_allowed_clients"} - with patch("litellm.proxy.proxy_server.general_settings", {"mcp_allowed_clients": ["claude-code"]}): + with patch( # test-quality-ok: the method writes this module global; no injection seam + "litellm.proxy.proxy_server.general_settings", {"mcp_allowed_clients": ["claude-code"]} + ): await proxy_config._update_general_settings(db_general_settings={"mcp_allowed_clients": ["codex-mcp-client"]}) import litellm.proxy.proxy_server as ps @@ -13936,18 +13896,14 @@ async def test_token_counter_loads_a_custom_tokenizer_off_the_event_loop(monkeyp { "model_name": "self-hosted", "litellm_params": {"model": "openai/self-hosted-model", "api_base": "http://localhost:8080/v1"}, - "model_info": { - "custom_tokenizer": {"identifier": "my-org/tokenizer", "revision": "main", "auth_token": None} - }, + "model_info": {"custom_tokenizer": {"identifier": "my-org/tokenizer", "revision": "main", "auth_token": None}}, } ] ), ) response, took, lags = await timed_with_loop_lags( - lambda: proxy_server_module.token_counter( - TokenCountRequest(model="self-hosted", prompt="count me off the loop") - ) + lambda: proxy_server_module.token_counter(TokenCountRequest(model="self-hosted", prompt="count me off the loop")) ) assert response.tokenizer_type == "huggingface_tokenizer" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 872875cc535..52b20017a74 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26619,6 +26619,11 @@ export interface components { * @description Maximum retention period for spend logs (e.g., '7d' for 7 days). Logs older than this will be deleted. */ maximum_spend_logs_retention_period?: string | null; + /** + * Mcp Allowed Clients + * @description MCP client applications admitted by the gateway, matched exactly against the clientInfo.name the client sends in its initialize request (for example 'claude-code'). When set, an initialize from any other client, or one that does not identify itself, is rejected with 403. Unset means every client is admitted. The name is client-supplied, so this is a policy control rather than a security boundary. + */ + mcp_allowed_clients?: string[] | null; /** * Mcp Internal Ip Ranges * @description Custom CIDR ranges that define internal/private networks for MCP access control. When set, only these ranges are treated as internal. Defaults to RFC 1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8).