diff --git a/litellm/constants.py b/litellm/constants.py index b8048ad30b4..19d1034f432 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -373,6 +373,7 @@ BEDROCK_REALTIME_SDK_DISTRIBUTION: Final = "aws-sdk-bedrock-runtime" BEDROCK_REALTIME_SDK_SUPPORTED_RANGE: Final = ">=0.10.0,<0.12.0" CLIENT_REQUESTED_MODEL_SCOPE_KEY: Final = "litellm.client_requested_model" MODEL_GROUP_ALIAS_RESOLVED_SCOPE_KEY: Final = "litellm.model_group_alias_resolved" +MCP_PEEKED_BODY_SCOPE_KEY: Final = "litellm_mcp_peeked_body" REALTIME_SESSION_SUCCESS_LOGGED_KEY: Final = "realtime_session_success_logged" REALTIME_SESSION_FAILURE_LOGGED_KEY: Final = "realtime_session_failure_logged" diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index ab14a317a66..b3ba3e38887 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -1,5 +1,5 @@ import re -from collections.abc import Mapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Set as AbstractSet from dataclasses import dataclass from datetime import datetime, timezone @@ -15,7 +15,7 @@ from typing_extensions import assert_never import litellm from litellm._internal_context import with_service_target from litellm._logging import verbose_logger -from litellm.constants import MCP_ALL_TOOLS_WILDCARD +from litellm.constants import MCP_ALL_TOOLS_WILDCARD, MCP_PEEKED_BODY_SCOPE_KEY from litellm.proxy._experimental.mcp_server.catalog import global_manager from litellm.proxy._experimental.mcp_server.oauth_utils import ( get_passthrough_resource_metadata_url, @@ -86,6 +86,22 @@ if TYPE_CHECKING: _EMPTY_TOOLSET_GRANTS: Final[Mapping[str, Sequence[str]]] = MappingProxyType({}) +def _admission_request(scope: Scope) -> Request: + """Request whose ``body()`` serves the routing layer's lazily peeked JSON-RPC + bytes (``b"{}"`` when no peek callable was stashed) instead of the ASGI + receive channel.""" + request: Final = Request(scope=scope) + peeked_body: Final[Callable[[], Awaitable[bytes]] | None] = scope.get(MCP_PEEKED_BODY_SCOPE_KEY) + + async def mock_body() -> bytes: + if peeked_body is None: + return b"{}" + return await peeked_body() + + request.body = mock_body + return request + + def _as_list(values: Sequence[str] | None) -> list[str] | None: # mutable-ok: resolver returns a list """Widen a read-only allowlist back to the mutable list the resolver's own contract returns, preserving the ``None`` that means "no restriction".""" @@ -497,12 +513,7 @@ class MCPRequestHandler: if mcp_servers_header == "" or (mcp_servers is not None and len(mcp_servers) == 0): mcp_servers = [] # Create a proper Request object with mock body method to avoid ASGI receive channel issues - request: Final = Request(scope=scope) - - async def mock_body(): - return b"{}" - - request.body = mock_body + request: Final = _admission_request(scope) # Inline import — auth_utils participates in a proxy import cycle. from litellm.proxy.auth.auth_utils import ( # noqa: PLC0415 get_request_route, diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index d1ac574dc91..b72841f557e 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -27,6 +27,7 @@ from starlette.types import Message, Receive, Scope, Send from litellm._logging import verbose_logger from litellm.constants import ( MCP_GATEWAY_SESSION_ID_PREFIX_LENGTH, + MCP_PEEKED_BODY_SCOPE_KEY, ) from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, @@ -1251,6 +1252,14 @@ if MCP_AVAILABLE: return f"ip:{hashlib.sha256(client_ip.encode('utf-8')).hexdigest()}" return "anonymous" + def _peeked_json_object_body(body: bytes) -> bytes | None: + """The peeked bytes when they form a complete JSON-RPC object, else None so a + truncated prefix or a batch array fails closed and stays budget-enforced.""" + try: + return body if isinstance(json.loads(body), dict) else None + except (json.JSONDecodeError, TypeError): + return None + def _is_initialize_request(body: bytes) -> bool: """ Check if the request body is a JSON-RPC initialize method. @@ -1399,7 +1408,7 @@ if MCP_AVAILABLE: Stops reading from the wire as soon as either (a) we have peeked ``_MCP_ROUTING_PEEK_MAX_BYTES`` of body, or (b) the body is complete. The remainder of an oversized body is streamed lazily through - ``wrapped_receive`` in the caller — so an authenticated client cannot + ``_LazyPeekedBody.receive`` in the caller — so an authenticated client cannot force the proxy to buffer an arbitrarily large payload just to make a routing decision. """ @@ -1437,6 +1446,29 @@ if MCP_AVAILABLE: return consumed_messages, b"".join(body_chunks) + class _LazyPeekedBody: + """Reads the POST body for routing and auth only when first asked, so an + allowlist rejection never touches the receive channel. Whatever it + consumed is replayed to the downstream handler through ``receive``, + which falls through to the wire once the replay buffer is drained.""" + + __slots__ = ("_consumed_messages", "_peeked_body", "_receive") + + def __init__(self, receive: Receive) -> None: + self._receive = receive + self._consumed_messages: list[Message] | None = None + self._peeked_body: bytes = b"" + + async def body(self) -> bytes: + if self._consumed_messages is None: + self._consumed_messages, self._peeked_body = await _read_request_body_for_routing(self._receive) + return self._peeked_body + + async def receive(self) -> Message: + if self._consumed_messages: + return self._consumed_messages.pop(0) + return await self._receive() + async def _handle_stale_mcp_session( scope: Scope, receive: Receive, @@ -2017,6 +2049,14 @@ if MCP_AVAILABLE: )(scope, receive, send) return path: Final[str] = scope.get("path", "") + peek: Final = _LazyPeekedBody(receive) if scope.get("method") == "POST" else None + if peek is not None: + receive = peek.receive # rebind-ok: replay peeked ASGI messages to the downstream handler + + async def peeked_json_object_body() -> bytes: + return _peeked_json_object_body(await peek.body()) or b"{}" + + scope[MCP_PEEKED_BODY_SCOPE_KEY] = peeked_json_object_body ( user_api_key_auth, mcp_auth_header, @@ -2026,6 +2066,8 @@ if MCP_AVAILABLE: raw_headers, ) = await extract_mcp_auth_context(scope, path) reject_disallowed_mcp_client(StarletteRequest(scope).headers, user_api_key_auth) + body: Final = await peek.body() if peek is not None else b"" + is_initialize: Final = _is_initialize_request(body) scoped_server_endpoint: Final = len(_get_mcp_servers_in_path(path) or []) == 1 # Extract client IP for MCP access control @@ -2097,8 +2139,6 @@ if MCP_AVAILABLE: # - No session ID + initialize → stateful (so client gets mcp-session-id) # - No session ID + other → stateless (curl, Inspector, Notion) session_id = _get_session_id_from_scope(scope) - is_initialize = False - consumed_messages: list[Message] = [] # Owner-binding: a live stateful session may only be driven by the # caller that created it. Reject mismatches with 403 so a leaked @@ -2106,8 +2146,7 @@ if MCP_AVAILABLE: # # Run before ``_handle_stale_mcp_session`` so a non-owner cannot # force-clean another caller's residual tracking entries via a - # stale DELETE, and before peeking the request body so the 403 - # response sees a pristine ``receive`` channel. + # stale DELETE. if session_id: expected_owner: Final = _stateful_session_owners.get(session_id) request_owner = _owner_fingerprint_for(user_api_key_auth, oauth2_headers, _client_ip) @@ -2136,11 +2175,6 @@ if MCP_AVAILABLE: return session_id = _get_session_id_from_scope(scope) - body = b"" - if scope.get("method") == "POST": - consumed_messages, body = await _read_request_body_for_routing(receive) - is_initialize = _is_initialize_request(body) - use_stateful: Final = bool(session_id or is_initialize) target_manager: Final = session_manager_stateful if use_stateful else session_manager_stateless @@ -2169,17 +2203,6 @@ if MCP_AVAILABLE: await too_many_response(scope, receive, send) return - # Replay body messages if we consumed them for peeking - original_receive: Final = receive - if consumed_messages: - - async def wrapped_receive(): - if consumed_messages: - return consumed_messages.pop(0) - return await original_receive() - - receive = wrapped_receive - # Serialize requests on the same stateful session so concurrent # callers don't clobber each other's auth context mid-flight. # diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 1435033e1be..7df9f4bb031 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -971,6 +971,20 @@ BUDGET_ENFORCED_SIDE_EFFECT_ROUTES: Final = frozenset( } ) +MCP_DISCOVERY_ROUTES: Final = frozenset({"/mcp-rest/tools/list", "/v1/mcp/tools", "/mcp/tools", "/mcp/tools/list"}) + +MCP_ZERO_SPEND_JSONRPC_METHODS: Final = frozenset({"initialize", "notifications/initialized", "ping", "tools/list"}) + +MCP_TOOL_CALL_ROUTES: Final = frozenset({"/mcp/tools/call", "/mcp-rest/tools/call"}) + + +def is_mcp_discovery_request(route: str, request_body: Mapping[str, object]) -> bool: + if route in MCP_DISCOVERY_ROUTES: + return True + if route in MCP_TOOL_CALL_ROUTES or not (route == "/mcp" or route.startswith("/mcp/")): + return False + return request_body.get("method") in MCP_ZERO_SPEND_JSONRPC_METHODS + def route_skips_budget_checks(route: str) -> bool: return route not in BUDGET_ENFORCED_SIDE_EFFECT_ROUTES and ( @@ -1029,7 +1043,11 @@ async def common_checks( team_id=valid_token.team_id if valid_token is not None else None, ) - skip_all_budget_checks: Final = skip_budget_checks or route_skips_budget_checks(route=route) + skip_all_budget_checks: Final = ( + skip_budget_checks + or route_skips_budget_checks(route=route) + or is_mcp_discovery_request(route=route, request_body=_typed_request_body(request_body)) + ) membership_user_id: Final = ( valid_token.user_id if valid_token is not None and (bool(_model) or not skip_all_budget_checks) else None diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 66bc5ae44d6..d30a3a0ce15 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -71,6 +71,7 @@ from litellm.proxy.auth.auth_checks import ( # noqa: F401 # legacy module expo get_team_object, get_user_object, get_user_role, + is_mcp_discovery_request, is_model_cost_zero, is_user_proxy_admin, is_valid_fallback_model, @@ -1549,7 +1550,7 @@ async def user_api_key_auth_builder( anthropic_api_key_header: str | None, google_ai_studio_api_key_header: str | None, azure_apim_header: str | None, - request_data: dict, + request_data: dict[str, object], custom_litellm_key_header: str | None = None, ) -> UserAPIKeyAuth: from litellm.proxy.proxy_server import ( @@ -1887,8 +1888,8 @@ async def user_api_key_auth_builder( llm_router=llm_router, team_id=valid_token.team_id, ) - skip_budget_checks = False - if model is not None and llm_router is not None: + skip_budget_checks = is_mcp_discovery_request(route=route, request_body=request_data) + if not skip_budget_checks and model is not None and llm_router is not None: from litellm.proxy.auth.auth_checks import is_model_cost_zero skip_budget_checks = ( # rebind-ok: pre-existing rebinding on a rename-only line @@ -2262,9 +2263,7 @@ async def user_api_key_auth_builder( return await validate_resolved_virtual_key( request=request, - request_data=cast( # cast-ok: model-alias checks must mutate the original request - dict[str, object], request_data - ), + request_data=request_data, valid_token=valid_token, api_key=api_key, route=route, @@ -2389,8 +2388,8 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e llm_router=llm_router, team_id=valid_token.team_id, ) - skip_budget_checks = False - if model is not None and llm_router is not None: + skip_budget_checks = is_mcp_discovery_request(route=route, request_body=request_data) + if not skip_budget_checks and model is not None and llm_router is not None: from litellm.proxy.auth.auth_checks import is_model_cost_zero skip_budget_checks = is_model_cost_zero( # rebind-ok: pre-existing rebinding on a rename-only line @@ -3294,7 +3293,7 @@ async def _reserve_budget_after_common_checks( def _should_skip_budget_checks( - request_data: dict, + request_data: dict[str, object], route: str, request: Request | None, llm_router: litellm.Router | None, @@ -3307,6 +3306,8 @@ def _should_skip_budget_checks( llm_router=llm_router, team_id=team_id, ) + if is_mcp_discovery_request(route=route, request_body=request_data): + return True if model is not None and llm_router is not None: return is_model_cost_zero(model=model, llm_router=llm_router) return False diff --git a/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 281bb8f413b..7704edcb16e 100644 --- a/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -10311,3 +10311,28 @@ async def test_catalog_refresh_uses_current_virtual_key_policy_and_keeps_session assert caller.team_id == "old-team" and not caller.requires_fresh_policy assert refreshed.access_group_ids == current_groups assert caller.access_group_ids == ["original-group"] + + +@pytest.mark.asyncio +async def test_admission_request_body_serves_stashed_peek_callable(): + from litellm.constants import MCP_PEEKED_BODY_SCOPE_KEY + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import _admission_request + + jsonrpc_body = b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' + + async def peek() -> bytes: + return jsonrpc_body + + with_peek = _admission_request( + { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [], + MCP_PEEKED_BODY_SCOPE_KEY: peek, + } + ) + assert await with_peek.body() == jsonrpc_body + + without_peek = _admission_request({"type": "http", "method": "POST", "path": "/mcp", "headers": []}) + assert await without_peek.body() == b"{}" diff --git a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server.py index 5ae3c595c53..18fbef1a7da 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server.py @@ -1,5 +1,7 @@ # Create server parameters for stdio connection import os +from typing import Final + import pytest from unittest.mock import ANY, AsyncMock, MagicMock, patch from contextlib import asynccontextmanager, nullcontext @@ -3105,3 +3107,190 @@ async def test_call_mcp_tool_resolves_unprefixed_tool_name_and_checks_permission assert mock_get_server.call_args_list[0][0][0] == "gmail_send_email" # Permissions check should be invoked with the resolved server name mock_is_allowed.assert_called_once() + + +@pytest.mark.asyncio +async def test_mcp_routing_stashes_peeked_body_for_auth(): + """The auth-time Request built by ``process_mcp_request`` cannot read the ASGI + receive channel; the routing peek must stash the real JSON-RPC body in scope so + ``is_mcp_discovery_request`` can see ``method`` and an over-budget key is not + 429'd on zero-spend ``initialize`` / ``tools/list``.""" + try: + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCP_PEEKED_BODY_SCOPE_KEY, + _admission_request, + ) + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateful, + session_manager_stateless, + ) + from litellm.proxy.common_utils.http_parsing_utils import _read_request_body + except ImportError: + pytest.skip("MCP server not available") + + jsonrpc_body: Final = b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"content-type", b"application/json"), + (b"authorization", b"Bearer test-key"), + ], + } + receive = AsyncMock(side_effect=[{"type": "http.request", "body": jsonrpc_body, "more_body": False}]) + send = AsyncMock() + stateless_called = [] + + async def stateless_handle(s, r, se) -> None: + stateless_called.append(1) # mutable-ok: records the manager the handler dispatched to + + with ( + patch( # test-quality-ok: the ASGI handler reads auth from a module-level helper; the suite's only seam + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(MagicMock(), None, None, None, None, None), + ), + patch( # test-quality-ok: registry is empty in unit tests; key owns one server + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new_callable=AsyncMock, + return_value=[MagicMock()], + ), + patch( # test-quality-ok: init flag is a module global; no injection seam + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateless, "handle_request", side_effect=stateless_handle + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateless, "_server_instances", {} + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateful, "_server_instances", {} + ), + ): + await handle_streamable_http_mcp(scope, receive, send) + + assert stateless_called, "tools/list without a session should route to the stateless manager" + assert await scope[MCP_PEEKED_BODY_SCOPE_KEY]() == jsonrpc_body + request_data: Final = await _read_request_body(_admission_request(scope)) + assert request_data.get("method") == "tools/list" + + +@pytest.mark.asyncio +async def test_mcp_routing_batch_body_is_not_stashed_for_auth(): + """A JSON-RPC batch (array) body must not be exposed to auth: it can carry a + spend-bearing ``tools/call`` alongside discovery methods, so it fails closed + and stays budget-enforced.""" + try: + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCP_PEEKED_BODY_SCOPE_KEY, + _admission_request, + ) + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateless, + ) + from litellm.proxy.common_utils.http_parsing_utils import _read_request_body + except ImportError: + pytest.skip("MCP server not available") + + batch_body: Final = b'[{"jsonrpc":"2.0","id":1,"method":"tools/list"},{"jsonrpc":"2.0","id":2,"method":"tools/call"}]' + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"content-type", b"application/json"), + (b"authorization", b"Bearer test-key"), + ], + } + receive = AsyncMock(side_effect=[{"type": "http.request", "body": batch_body, "more_body": False}]) + send = AsyncMock() + + with ( + patch( # test-quality-ok: the ASGI handler reads auth from a module-level helper; the suite's only seam + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(MagicMock(), None, None, None, None, None), + ), + patch( # test-quality-ok: registry is empty in unit tests; key owns one server + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new_callable=AsyncMock, + return_value=[MagicMock()], + ), + patch( # test-quality-ok: init flag is a module global; no injection seam + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateless, "handle_request", new=AsyncMock() + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateless, "_server_instances", {} + ), + ): + await handle_streamable_http_mcp(scope, receive, send) + + assert await scope[MCP_PEEKED_BODY_SCOPE_KEY]() == b"{}" + assert await _read_request_body(_admission_request(scope)) == {} + + +@pytest.mark.asyncio +async def test_mcp_routing_truncated_json_body_is_not_stashed_for_auth(): + """A peek truncated mid-JSON (oversized body) must fail closed for auth: the + scope callable yields ``b"{}"`` so the request stays budget-enforced.""" + try: + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCP_PEEKED_BODY_SCOPE_KEY, + _admission_request, + ) + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateless, + ) + from litellm.proxy.common_utils.http_parsing_utils import _read_request_body + except ImportError: + pytest.skip("MCP server not available") + + truncated_body: Final = b'{"jsonrpc":"2.0","id":1,"method":"tools' + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"content-type", b"application/json"), + (b"authorization", b"Bearer test-key"), + ], + } + receive = AsyncMock(side_effect=[{"type": "http.request", "body": truncated_body, "more_body": False}]) + send = AsyncMock() + + with ( + patch( # test-quality-ok: the ASGI handler reads auth from a module-level helper; the suite's only seam + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(MagicMock(), None, None, None, None, None), + ), + patch( # test-quality-ok: registry is empty in unit tests; key owns one server + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new_callable=AsyncMock, + return_value=[MagicMock()], + ), + patch( # test-quality-ok: init flag is a module global; no injection seam + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateless, "handle_request", new=AsyncMock() + ), + patch.object( # test-quality-ok: session managers are module-level singletons; the suite's only seam + session_manager_stateless, "_server_instances", {} + ), + ): + await handle_streamable_http_mcp(scope, receive, send) + + assert await scope[MCP_PEEKED_BODY_SCOPE_KEY]() == b"{}" + assert await _read_request_body(_admission_request(scope)) == {} diff --git a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_tool_calls_and_headers.py b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_tool_calls_and_headers.py index 97c6e2703ad..25d51db8896 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_tool_calls_and_headers.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_tool_calls_and_headers.py @@ -11149,3 +11149,61 @@ async def test_discovery_adapter_preserves_authenticated_context(_mcp_request_ct context = dispatched.await_args.args[1] assert context.user_api_key_auth.user_id == "discover-caller" assert context.mcp_servers == ("allowed",) + + +@pytest.mark.asyncio +async def test_streamable_http_admission_auth_sees_peeked_jsonrpc_body() -> None: + """The lazy peek must expose the JSON-RPC body to admission auth (via the + scope callable) while the replayed receive still hands the full body to the + downstream session manager.""" + from litellm.constants import MCP_PEEKED_BODY_SCOPE_KEY + from litellm.proxy._experimental.mcp_server import server as mcp_module + + scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": []} + receive: Final = AsyncMock( + side_effect=[ + {"type": "http.request", "body": _INITIALIZE[:20], "more_body": True}, + {"type": "http.request", "body": _INITIALIZE[20:], "more_body": False}, + ] + ) + send: Final = AsyncMock() + downstream_bodies: Final[list[bytes]] = [] + auth_bodies: Final[list[bytes]] = [] + + async def extract(auth_scope: Scope, _: str): + auth_bodies.append(await auth_scope[MCP_PEEKED_BODY_SCOPE_KEY]()) + return (UserAPIKeyAuth(user_id="allowlist-user", jwt_claims=_LISTED_JWT), None, None, None, None, {}) + + async def handle_request(_: Scope, downstream_receive: Receive, __: Send) -> None: + downstream_bodies.append(await _drain_body(downstream_receive)) + + stateful_handle: Final = AsyncMock(side_effect=handle_request) + stateless_handle: Final = AsyncMock() + + with ( + 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, + side_effect=extract, + ), + 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", _ALLOWLIST_SETTINGS + ), + 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( # 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), + ), + ): + await mcp_module.handle_streamable_http_mcp(scope, receive, send) + + assert auth_bodies == [_INITIALIZE] + assert downstream_bodies == [_INITIALIZE] + stateless_handle.assert_not_awaited() + send.assert_not_awaited() diff --git a/tests/unit/proxy/auth/test_auth_checks.py b/tests/unit/proxy/auth/test_auth_checks.py index dfd00a7458f..4bd9bc2d9d3 100644 --- a/tests/unit/proxy/auth/test_auth_checks.py +++ b/tests/unit/proxy/auth/test_auth_checks.py @@ -1637,3 +1637,122 @@ def test_is_model_cost_zero_judges_an_alias_chain_by_the_deployment_its_entry_ro assert verdicts == expected assert {name: is_model_cost_zero(model=name, llm_router=router) for name in order} == expected + + +MCP_DISCOVERY_REST_ROUTES = [ + "/mcp-rest/tools/list", + "/v1/mcp/tools", + "/mcp/tools", + "/mcp/tools/list", +] + + +@pytest.mark.parametrize("route", MCP_DISCOVERY_REST_ROUTES) +@pytest.mark.asyncio +async def test_mcp_tool_discovery_route_bypasses_team_budget(route): + """An exhausted budget must not block zero-spend MCP tool discovery.""" + from litellm.proxy.auth.auth_checks import common_checks + + team_object = LiteLLM_TeamTable(team_id="test-team", spend=150.0, max_budget=100.0) + + result = await common_checks( + request_body={}, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route=route, + llm_router=None, + proxy_logging_obj=AsyncMock(), + valid_token=UserAPIKeyAuth(token="test-token", team_id="test-team"), + request=MagicMock(), + ) + + assert result is True + + +@pytest.mark.parametrize("method", ["initialize", "notifications/initialized", "ping", "tools/list"]) +@pytest.mark.parametrize("route", ["/mcp", "/mcp/some-server"]) +@pytest.mark.asyncio +async def test_mcp_jsonrpc_zero_spend_method_bypasses_team_budget(route, method): + """An exhausted budget must not block zero-spend JSON-RPC methods on the /mcp transport.""" + from litellm.proxy.auth.auth_checks import common_checks + + team_object = LiteLLM_TeamTable(team_id="test-team", spend=150.0, max_budget=100.0) + + result = await common_checks( + request_body={"jsonrpc": "2.0", "id": 1, "method": method, "params": {}}, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route=route, + llm_router=None, + proxy_logging_obj=AsyncMock(), + valid_token=UserAPIKeyAuth(token="test-token", team_id="test-team"), + request=MagicMock(), + ) + + assert result is True + + +@pytest.mark.parametrize( + "route,request_body", + [ + ("/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "add"}}), + ("/mcp/some-server", {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "add"}}), + ("/mcp-rest/tools/call", {"name": "add", "arguments": {}}), + ("/mcp/tools/call", {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}), + ], +) +@pytest.mark.asyncio +async def test_mcp_tool_call_route_still_enforces_team_budget(route, request_body): + """tools/call is spend-bearing and must stay budget-enforced, including when the + tools/call REST route carries a discovery-looking JSON-RPC body.""" + from litellm.proxy.auth.auth_checks import common_checks + + team_object = LiteLLM_TeamTable(team_id="test-team", spend=150.0, max_budget=100.0) + + with pytest.raises(litellm.BudgetExceededError): + await common_checks( + request_body=request_body, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route=route, + llm_router=None, + proxy_logging_obj=AsyncMock(), + valid_token=UserAPIKeyAuth(token="test-token", team_id="test-team"), + request=MagicMock(), + ) + + +@pytest.mark.parametrize( + "route,request_body,expected", + [ + ("/mcp-rest/tools/list", {}, True), + ("/v1/mcp/tools", {}, True), + ("/mcp/tools", {}, True), + ("/mcp/tools/list", {}, True), + ("/mcp", {"method": "initialize"}, True), + ("/mcp", {"method": "notifications/initialized"}, True), + ("/mcp", {"method": "ping"}, True), + ("/mcp", {"method": "tools/list"}, True), + ("/mcp/some-server", {"method": "tools/list"}, True), + ("/mcp", {"method": "tools/call"}, False), + ("/mcp/some-server", {"method": "tools/call"}, False), + ("/mcp", {}, False), + ("/mcp-rest/tools/call", {"method": "initialize"}, False), + ("/mcp/tools/call", {"method": "initialize"}, False), + ("/v1/chat/completions", {"method": "initialize"}, False), + ("/mcp-rest/other", {"method": "tools/list"}, False), + ], +) +def test_is_mcp_discovery_request(route, request_body, expected): + from litellm.proxy.auth.auth_checks import is_mcp_discovery_request + + assert is_mcp_discovery_request(route=route, request_body=request_body) is expected diff --git a/tests/unit/proxy/auth/test_user_api_key_auth.py b/tests/unit/proxy/auth/test_user_api_key_auth.py index 9cc8552efd5..808faa6444d 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth.py @@ -1900,3 +1900,71 @@ async def test_user_api_key_auth_websocket_rejection_adds_no_traceback_for_other proxy_records: Final = [record for record in caplog.records if record.name == verbose_proxy_logger.name] assert [record.getMessage() for record in proxy_records if record.exc_info is not None] == [] assert [record.levelno for record in proxy_records if record.levelno >= logging.WARNING] == [logging.ERROR] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "request_data, expect_user_model_budget_check", + [ + ({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, False), + ({"jsonrpc": "2.0", "id": 1, "method": "initialize"}, False), + ({"jsonrpc": "2.0", "id": 1, "method": "tools/call"}, True), + ], +) +async def test_jwt_path_skips_user_model_budget_only_for_zero_spend_mcp_methods( + monkeypatch: pytest.MonkeyPatch, + request_data: dict[str, object], + expect_user_model_budget_check: bool, +) -> None: + from litellm.caching.dual_cache import DualCache + from litellm.proxy._types import LiteLLM_JWTAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth_builder + + jwt_response: Final = { + "is_proxy_admin": False, + "jwt_claims": {}, + "team_id": None, + "team_object": None, + "user_id": "jwt-user", + "user_email": None, + "user_object": LiteLLM_UserTable( + user_id="jwt-user", + model_max_budget={"gpt-4o": {"budget_limit": 1.0, "time_period": "1d"}}, + ), + "org_id": None, + "org_object": None, + "end_user_id": None, + "end_user_object": None, + "token": "fake.jwt.token", + } + mock_request: Final = MagicMock() + mock_request.url.path = "/mcp" + mock_request.method = "POST" + mock_request.headers = {"authorization": "Bearer fake.jwt.token"} + mock_request.query_params = {} + + monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", {"enable_jwt_auth": True}) + litellm.proxy.proxy_server.jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=DualCache(), + litellm_jwtauth=LiteLLM_JWTAuth(), + ) + user_model_budget_check: Final = AsyncMock() + + with ( + patch("litellm.proxy.proxy_server.premium_user", True), + patch("litellm.proxy.auth.handle_jwt.JWTAuthManager.auth_builder", return_value=jwt_response), + patch("litellm.proxy.auth.user_api_key_auth._check_user_model_budget", user_model_budget_check), + ): + result: Final = await user_api_key_auth_builder( + request=mock_request, + api_key="Bearer fake.jwt.token", + azure_api_key_header="", + anthropic_api_key_header=None, + google_ai_studio_api_key_header=None, + azure_apim_header=None, + request_data=request_data, + ) + + assert result.user_id == "jwt-user" + assert user_model_budget_check.await_count == (1 if expect_user_model_budget_check else 0) diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 4ba2c2c56f2..60f6e7e7a68 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -10413,3 +10413,93 @@ async def test_auto_register_mapping_insert_emits_a_postgres_insert_event_for_th "auto_register_jwt_mapping", {"table_name": "LiteLLM_JWTKeyMapping"}, ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "route, request_data, expect_blocked", + [ + ("/mcp-rest/tools/list", {}, False), + ("/v1/mcp/tools", {}, False), + ("/mcp", {"jsonrpc": "2.0", "id": 1, "method": "initialize"}, False), + ("/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, False), + ("/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/call"}, True), + ("/mcp", {}, True), + ("/mcp-rest/tools/call", {}, True), + ], +) +async def test_exhausted_key_budget_spares_only_zero_spend_mcp_discovery( + route: str, request_data: dict[str, object], expect_blocked: bool +) -> None: + from litellm.proxy.utils import hash_token + + api_key: Final = "sk-exhausted-mcp-discovery" + hashed_token: Final = hash_token(api_key) + user_api_key_cache: Final = DualCache() + await cache_key_object( + hashed_token=hashed_token, + user_api_key_obj=UserAPIKeyAuth(token=hashed_token, max_budget=2.0, spend=5.0), + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=None, + ) + + mock_request: Final = MagicMock() + mock_request.url.path = route + mock_request.method = "POST" + mock_request.headers = {"authorization": f"Bearer {api_key}"} + mock_request.query_params = {} + mock_request.state = SimpleNamespace() + + proxy_logging_obj: Final = MagicMock() + proxy_logging_obj.budget_alerts = AsyncMock() + proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) + + async def authenticate() -> UserAPIKeyAuth: + return await user_api_key_auth_builder( + request=mock_request, + api_key=f"Bearer {api_key}", + azure_api_key_header="", + anthropic_api_key_header=None, + google_ai_studio_api_key_header=None, + azure_apim_header=None, + request_data=request_data, + ) + + with ( + patch("litellm.proxy.proxy_server.general_settings", {}), + patch("litellm.proxy.proxy_server.master_key", "sk-master"), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.user_api_key_cache", user_api_key_cache), + patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj), + patch( + "litellm.proxy.auth.user_api_key_auth.virtual_key_max_budget_alert_check", + new_callable=AsyncMock, + ), + ): + if expect_blocked: + with pytest.raises(ProxyException) as exc_info: + await authenticate() + assert "Budget has been exceeded! Key=key Current cost: 5.0, Max budget: 2.0" in exc_info.value.message + else: + result: Final = await authenticate() + assert result.token == hashed_token + assert result.spend == 5.0 + + +@pytest.mark.parametrize( + "route, request_data, expected", + [ + ("/mcp-rest/tools/list", {}, True), + ("/mcp", {"jsonrpc": "2.0", "id": 1, "method": "ping"}, True), + ("/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/call"}, False), + ("/mcp-rest/tools/call", {}, False), + ], +) +def test_should_skip_budget_checks_spares_only_zero_spend_mcp_discovery( + route: str, request_data: dict[str, object], expected: bool +) -> None: + from litellm.proxy.auth.user_api_key_auth import _should_skip_budget_checks + + assert ( + _should_skip_budget_checks(request_data=request_data, route=route, request=None, llm_router=None) is expected + )