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 882ded78e2f..4d999919f2f 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 @@ -447,6 +447,8 @@ class MCPRequestHandler: ) is not None and not oauth2_headers + and not mcp_server_auth_headers + and not mcp_auth_header ): validated_user_api_key_auth = UserAPIKeyAuth() elif ( @@ -456,9 +458,13 @@ class MCPRequestHandler: client_ip=IPAddressUtils.get_mcp_client_ip(request), ) ) is not None and oauth2_headers: - validated_user_api_key_auth, mcp_server_auth_headers = await MCPRequestHandler._admit_dcr_bridge_delegate( + ( + validated_user_api_key_auth, + mcp_server_auth_headers, + ) = await MCPRequestHandler._admit_dcr_bridge_authorization( server=bridge_delegate_target, authorization_value=oauth2_headers["Authorization"], + litellm_api_key=litellm_api_key, mcp_server_auth_headers=mcp_server_auth_headers, request=request, route=request_route, @@ -802,28 +808,57 @@ class MCPRequestHandler: new_headers: Final = {**(mcp_server_auth_headers or {}), **injected} return admitted, new_headers case BridgeEnvelopeInvalid() | NotBridgeEnvelope(): - resource_name: Final = server.alias or server.server_name - if resource_name is None: - raise HTTPException( - status_code=500, - detail="Server misconfigured: MCP server has no routable name", - ) - raise HTTPException( - status_code=401, - detail="Invalid or expired credential", - headers=MappingProxyType( - { - "www-authenticate": get_passthrough_www_authenticate( - scope=request.scope, - server_name=resource_name, - invalid_token=True, - ) - } - ), - ) + raise MCPRequestHandler._dcr_bridge_invalid_token_challenge(server=server, request=request) case _: assert_never(result) + @staticmethod + async def _admit_dcr_bridge_authorization( + server: MCPServer, + authorization_value: str, + litellm_api_key: str, + mcp_server_auth_headers: dict[str, dict[str, str]] | None, + request: Request, + route: str, + ) -> tuple[UserAPIKeyAuth, dict[str, dict[str, str]] | None]: + if is_bridge_envelope_shaped(authorization_value): + return await MCPRequestHandler._admit_dcr_bridge_delegate( + server=server, + authorization_value=authorization_value, + mcp_server_auth_headers=mcp_server_auth_headers, + request=request, + route=route, + ) + try: + admitted: Final = await user_api_key_auth(api_key=litellm_api_key, request=request) + except (HTTPException, ProxyException) as exc: + if not _is_litellm_auth_admission_error(exc): + raise + raise MCPRequestHandler._dcr_bridge_invalid_token_challenge(server=server, request=request) from exc + return admitted, mcp_server_auth_headers + + @staticmethod + def _dcr_bridge_invalid_token_challenge(server: MCPServer, request: Request) -> HTTPException: + resource_name: Final = server.alias or server.server_name + if resource_name is None: + raise HTTPException( + status_code=500, + detail="Server misconfigured: MCP server has no routable name", + ) + return HTTPException( + status_code=401, + detail="Invalid or expired credential", + headers=MappingProxyType( + { + "www-authenticate": get_passthrough_www_authenticate( + scope=request.scope, + server_name=resource_name, + invalid_token=True, + ) + } + ), + ) + @staticmethod async def _admit_gateway_session( authorization_value: str, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 0fa840e4dd3..52badaba5ac 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -5320,6 +5320,37 @@ class TestMCPDcrBridgeDelegateAdmission: assert auth_result == UserAPIKeyAuth() assert mcp_server_auth_headers == {} + @pytest.mark.parametrize( + "headers", + ( + [(b"x-mcp-auth", b"Bearer upstream-token")], + [(b"x-mcp-bridge_delegate_server-authorization", b"Bearer upstream-token")], + ), + ids=("deprecated-mcp-auth", "per-server-auth"), + ) + async def test_client_mcp_credentials_do_not_receive_keyless_bridge_admission(self, headers): + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": headers, + } + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + side_effect=HTTPException(status_code=401, detail="Invalid key"), + ) as mock_auth, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 401 + mock_auth.assert_awaited_once() + async def test_valid_envelope_reloads_live_key_and_admits_its_authorization_context(self): """A valid envelope admits under the LIVE key record the sealed key_hash references, not a blank identity: the reload is keyed by that exact hash, and the admitted auth carries the @@ -6058,6 +6089,7 @@ class TestMCPDcrBridgeDelegateAdmission: patch( "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, + side_effect=HTTPException(status_code=401, detail="Invalid key"), ) as mock_auth, patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), @@ -6067,7 +6099,7 @@ class TestMCPDcrBridgeDelegateAdmission: await MCPRequestHandler.process_mcp_request(scope) assert exc_info.value.status_code == 401 - mock_auth.assert_not_called() + mock_auth.assert_awaited_once() assert exc_info.value.headers == { "www-authenticate": ( 'Bearer error="invalid_token", ' @@ -6075,6 +6107,62 @@ class TestMCPDcrBridgeDelegateAdmission: ) } + async def test_valid_litellm_authorization_key_uses_standard_admission(self): + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": [(b"authorization", b"Bearer sk-valid-litellm-key")], + } + admitted = UserAPIKeyAuth(api_key="hashed-key", user_id="litellm-key-user") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + return_value=admitted, + ) as mock_auth, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + ( + auth_result, + _mcp_auth, + _servers, + mcp_server_auth_headers, + _oauth, + _raw, + ) = await MCPRequestHandler.process_mcp_request(scope) + + assert auth_result is admitted + assert mcp_server_auth_headers == {} + assert mock_auth.await_args.kwargs["api_key"] == "Bearer sk-valid-litellm-key" + + async def test_non_401_litellm_key_failure_is_not_converted_to_oauth_challenge(self): + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": [(b"authorization", b"Bearer sk-blocked-litellm-key")], + } + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + side_effect=HTTPException(status_code=403, detail="Key blocked"), + ), + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 403 + assert not exc_info.value.headers + async def test_explicit_litellm_key_wins_over_envelope_arm(self): """An explicit x-litellm-api-key is always a LiteLLM credential and its arm precedes the envelope arm: user_api_key_auth validates the key and NO inner token is injected, even