Przeglądaj źródła

feat(mcp): Add get_cost_recommendations tool

Implements a new MCP tool that generates actionable cost optimization
recommendations. The tool analyzes Kubernetes allocation data and identifies:

- Idle resources: workloads with <5% CPU and memory utilization
- Oversized resources: workloads with <30% efficiency
- Rightsizing opportunities: general optimization suggestions

Features:
- Recommendations sorted by potential savings (highest first)
- Priority levels (high/medium/low) based on savings percentage
- Configurable buffer multiplier for sizing recommendations
- Minimum savings threshold filtering
- TopN limiting for result sets
- Summary statistics with breakdowns by type and priority
- Filter support for targeting specific namespaces/pods

The implementation follows existing MCP patterns and includes comprehensive
unit tests covering all recommendation types and edge cases.
Claude 5 miesięcy temu
rodzic
commit
eb24067137
3 zmienionych plików z 975 dodań i 9 usunięć
  1. 45 0
      pkg/cmd/costmodel/costmodel.go
  2. 441 9
      pkg/mcp/server.go
  3. 489 0
      pkg/mcp/server_test.go

+ 45 - 0
pkg/cmd/costmodel/costmodel.go

@@ -259,6 +259,34 @@ func StartMCPServer(ctx context.Context, accesses *costmodel.Accesses, cloudCost
 		return nil, mcpResp, nil
 	}
 
+	handleRecommendations := func(ctx context.Context, req *mcp_sdk.CallToolRequest, args RecommendationsArgs) (*mcp_sdk.CallToolResult, interface{}, error) {
+		queryRequest := &opencost_mcp.OpenCostQueryRequest{
+			QueryType: opencost_mcp.RecommendationsQueryType,
+			Window:    args.Window,
+			RecommendationsParams: &opencost_mcp.RecommendationsQuery{
+				Aggregate:        args.Aggregate,
+				Filter:           args.Filter,
+				BufferMultiplier: args.BufferMultiplier,
+				MinSavings:       args.MinSavings,
+				IncludeIdle:      args.IncludeIdle,
+				IncludeOversized: args.IncludeOversized,
+				IncludeRightsize: args.IncludeRightsize,
+				TopN:             args.TopN,
+			},
+		}
+
+		mcpReq := &opencost_mcp.MCPRequest{
+			Query: queryRequest,
+		}
+
+		mcpResp, err := mcpServer.ProcessMCPRequest(mcpReq)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to process recommendations request: %w", err)
+		}
+
+		return nil, mcpResp, nil
+	}
+
 	// Register tools
 	mcp_sdk.AddTool(sdkServer, &mcp_sdk.Tool{
 		Name:        "get_allocation_costs",
@@ -280,6 +308,11 @@ func StartMCPServer(ctx context.Context, accesses *costmodel.Accesses, cloudCost
 		Description: "Retrieves resource efficiency metrics with rightsizing recommendations and cost savings analysis. Computes CPU and memory efficiency (usage/request ratio), provides recommended resource requests, and calculates potential cost savings. Optional buffer_multiplier parameter (default: 1.2 for 20% headroom) can be set to values like 1.4 for 40% headroom.",
 	}, handleEfficiency)
 
+	mcp_sdk.AddTool(sdkServer, &mcp_sdk.Tool{
+		Name:        "get_cost_recommendations",
+		Description: "Generates actionable cost optimization recommendations. Identifies idle resources (very low utilization), oversized resources (low efficiency), and rightsizing opportunities. Returns prioritized recommendations sorted by potential savings. Supports filtering by namespace, aggregation level, and minimum savings threshold. Each recommendation includes current vs recommended resource requests, estimated savings, and specific actions to take.",
+	}, handleRecommendations)
+
 	// Create HTTP handler
 	handler := mcp_sdk.NewStreamableHTTPHandler(func(r *http.Request) *mcp_sdk.Server {
 		return sdkServer
@@ -355,3 +388,15 @@ type EfficiencyArgs struct {
 	Filter           string   `json:"filter,omitempty"`            // Filter expression (same as allocation filters)
 	BufferMultiplier *float64 `json:"buffer_multiplier,omitempty"` // Buffer multiplier for recommendations (default: 1.2 for 20% headroom, e.g., 1.4 for 40%)
 }
+
+type RecommendationsArgs struct {
+	Window           string   `json:"window"`                      // Time window (e.g., "today", "yesterday", "7d", "lastweek")
+	Aggregate        string   `json:"aggregate,omitempty"`         // Aggregation level (e.g., "pod", "namespace", "controller")
+	Filter           string   `json:"filter,omitempty"`            // Filter expression (same as allocation filters)
+	BufferMultiplier *float64 `json:"buffer_multiplier,omitempty"` // Buffer multiplier for sizing recommendations (default: 1.2)
+	MinSavings       *float64 `json:"min_savings,omitempty"`       // Minimum savings threshold to include recommendation (default: 0.01)
+	IncludeIdle      bool     `json:"include_idle,omitempty"`      // Include idle resource detection
+	IncludeOversized bool     `json:"include_oversized,omitempty"` // Include oversized resource detection
+	IncludeRightsize bool     `json:"include_rightsize,omitempty"` // Include rightsizing recommendations
+	TopN             *int     `json:"top_n,omitempty"`             // Limit to top N recommendations by savings
+}

+ 441 - 9
pkg/mcp/server.go

@@ -25,10 +25,11 @@ import (
 type QueryType string
 
 const (
-	AllocationQueryType QueryType = "allocation"
-	AssetQueryType      QueryType = "asset"
-	CloudCostQueryType  QueryType = "cloudcost"
-	EfficiencyQueryType QueryType = "efficiency"
+	AllocationQueryType      QueryType = "allocation"
+	AssetQueryType           QueryType = "asset"
+	CloudCostQueryType       QueryType = "cloudcost"
+	EfficiencyQueryType      QueryType = "efficiency"
+	RecommendationsQueryType QueryType = "recommendations"
 )
 
 // Efficiency calculation constants
@@ -38,6 +39,43 @@ const (
 	efficiencyMinRAM           = 1024 * 1024 // 1 MB minimum RAM
 )
 
+// Recommendation thresholds
+const (
+	// Idle detection thresholds
+	idleCPUThreshold    = 0.05 // <5% CPU usage considered idle
+	idleMemoryThreshold = 0.05 // <5% memory usage considered idle
+
+	// Oversized detection thresholds
+	oversizedCPUThreshold    = 0.3 // <30% CPU efficiency is oversized
+	oversizedMemoryThreshold = 0.3 // <30% memory efficiency is oversized
+
+	// Underprovisioned detection thresholds
+	underprovisionedCPUThreshold    = 0.9 // >90% CPU efficiency may be underprovisioned
+	underprovisionedMemoryThreshold = 0.9 // >90% memory efficiency may be underprovisioned
+
+	// Minimum savings threshold for recommendations
+	minSavingsThreshold = 0.01 // Minimum $0.01 savings to generate recommendation
+)
+
+// RecommendationType defines the type of cost recommendation
+type RecommendationType string
+
+const (
+	RecommendationTypeIdle             RecommendationType = "idle"
+	RecommendationTypeOversized        RecommendationType = "oversized"
+	RecommendationTypeRightsize        RecommendationType = "rightsize"
+	RecommendationTypeUnderprovisioned RecommendationType = "underprovisioned"
+)
+
+// RecommendationPriority defines the priority level of a recommendation
+type RecommendationPriority string
+
+const (
+	RecommendationPriorityHigh   RecommendationPriority = "high"
+	RecommendationPriorityMedium RecommendationPriority = "medium"
+	RecommendationPriorityLow    RecommendationPriority = "low"
+)
+
 // MCPRequest represents a single turn in a conversation with the OpenCost MCP server.
 type MCPRequest struct {
 	SessionID string                `json:"sessionId"`
@@ -59,14 +97,15 @@ type QueryMetadata struct {
 
 // OpenCostQueryRequest provides a unified interface for all OpenCost query types.
 type OpenCostQueryRequest struct {
-	QueryType QueryType `json:"queryType" validate:"required,oneof=allocation asset cloudcost efficiency"`
+	QueryType QueryType `json:"queryType" validate:"required,oneof=allocation asset cloudcost efficiency recommendations"`
 
 	Window string `json:"window" validate:"required"`
 
-	AllocationParams *AllocationQuery `json:"allocationParams,omitempty"`
-	AssetParams      *AssetQuery      `json:"assetParams,omitempty"`
-	CloudCostParams  *CloudCostQuery  `json:"cloudCostParams,omitempty"`
-	EfficiencyParams *EfficiencyQuery `json:"efficiencyParams,omitempty"`
+	AllocationParams      *AllocationQuery      `json:"allocationParams,omitempty"`
+	AssetParams           *AssetQuery           `json:"assetParams,omitempty"`
+	CloudCostParams       *CloudCostQuery       `json:"cloudCostParams,omitempty"`
+	EfficiencyParams      *EfficiencyQuery      `json:"efficiencyParams,omitempty"`
+	RecommendationsParams *RecommendationsQuery `json:"recommendationsParams,omitempty"`
 }
 
 // AllocationQuery contains the parameters for an allocation query.
@@ -111,6 +150,18 @@ type EfficiencyQuery struct {
 	EfficiencyBufferMultiplier *float64 `json:"efficiencyBufferMultiplier,omitempty"` // Buffer multiplier for recommendations (default: 1.2 for 20% headroom)
 }
 
+// RecommendationsQuery contains the parameters for a cost recommendations query.
+type RecommendationsQuery struct {
+	Aggregate        string   `json:"aggregate,omitempty"`        // Aggregation level (e.g., "pod", "namespace", "controller")
+	Filter           string   `json:"filter,omitempty"`           // Filter expression for allocations
+	BufferMultiplier *float64 `json:"bufferMultiplier,omitempty"` // Buffer multiplier for sizing recommendations (default: 1.2)
+	MinSavings       *float64 `json:"minSavings,omitempty"`       // Minimum savings threshold to include recommendation (default: 0.01)
+	IncludeIdle      bool     `json:"includeIdle,omitempty"`      // Include idle resource detection (default: true)
+	IncludeOversized bool     `json:"includeOversized,omitempty"` // Include oversized resource detection (default: true)
+	IncludeRightsize bool     `json:"includeRightsize,omitempty"` // Include rightsizing recommendations (default: true)
+	TopN             *int     `json:"topN,omitempty"`             // Limit to top N recommendations by savings (default: no limit)
+}
+
 // AllocationResponse represents the allocation data returned to the AI agent.
 type AllocationResponse struct {
 	// The allocation data, as a map of allocation sets.
@@ -360,6 +411,58 @@ type EfficiencyMetric struct {
 	End   time.Time `json:"end"`
 }
 
+// RecommendationsResponse represents cost optimization recommendations returned to the AI agent.
+type RecommendationsResponse struct {
+	Recommendations []*Recommendation        `json:"recommendations"` // List of recommendations sorted by savings
+	Summary         *RecommendationsSummary  `json:"summary"`         // Summary of all recommendations
+	Window          *TimeWindow              `json:"window"`          // Time window analyzed
+}
+
+// RecommendationsSummary provides summary statistics for recommendations.
+type RecommendationsSummary struct {
+	TotalRecommendations int     `json:"totalRecommendations"` // Total number of recommendations
+	TotalPotentialSavings float64 `json:"totalPotentialSavings"` // Sum of all potential savings
+	ByType               map[string]int     `json:"byType"`               // Count by recommendation type
+	ByPriority           map[string]int     `json:"byPriority"`           // Count by priority level
+	IdleResourceCount    int     `json:"idleResourceCount"`    // Number of idle resources identified
+	OversizedCount       int     `json:"oversizedCount"`       // Number of oversized resources
+	RightsizeCount       int     `json:"rightsizeCount"`       // Number of rightsizing opportunities
+}
+
+// Recommendation represents a single cost optimization recommendation.
+type Recommendation struct {
+	ID          string                 `json:"id"`          // Unique identifier for the recommendation
+	Type        RecommendationType     `json:"type"`        // Type of recommendation (idle, oversized, rightsize)
+	Priority    RecommendationPriority `json:"priority"`    // Priority level (high, medium, low)
+	ResourceName string                `json:"resourceName"` // Name of the resource (pod, namespace, etc.)
+	Description string                 `json:"description"` // Human-readable description of the recommendation
+	Action      string                 `json:"action"`      // Recommended action to take
+
+	// Current state
+	CurrentCPURequest float64 `json:"currentCpuRequest"` // Current CPU request in cores
+	CurrentRAMRequest float64 `json:"currentRamRequest"` // Current RAM request in bytes
+	CurrentCPUUsage   float64 `json:"currentCpuUsage"`   // Current CPU usage in cores
+	CurrentRAMUsage   float64 `json:"currentRamUsage"`   // Current RAM usage in bytes
+	CurrentCost       float64 `json:"currentCost"`       // Current cost
+
+	// Efficiency metrics
+	CPUEfficiency    float64 `json:"cpuEfficiency"`    // Current CPU efficiency (0-1+)
+	MemoryEfficiency float64 `json:"memoryEfficiency"` // Current memory efficiency (0-1+)
+
+	// Recommendation details
+	RecommendedCPURequest float64 `json:"recommendedCpuRequest"` // Recommended CPU request
+	RecommendedRAMRequest float64 `json:"recommendedRamRequest"` // Recommended RAM request
+	RecommendedCost       float64 `json:"recommendedCost"`       // Estimated cost after optimization
+
+	// Savings analysis
+	EstimatedSavings       float64 `json:"estimatedSavings"`       // Estimated cost savings
+	EstimatedSavingsPercent float64 `json:"estimatedSavingsPercent"` // Savings as percentage
+
+	// Time window
+	Start time.Time `json:"start"`
+	End   time.Time `json:"end"`
+}
+
 // MCPServer holds the dependencies for the MCP API server.
 type MCPServer struct {
 	costModel    *costmodel.CostModel
@@ -399,6 +502,8 @@ func (s *MCPServer) ProcessMCPRequest(request *MCPRequest) (*MCPResponse, error)
 		data, err = s.QueryCloudCosts(request.Query)
 	case EfficiencyQueryType:
 		data, err = s.QueryEfficiency(request.Query)
+	case RecommendationsQueryType:
+		data, err = s.QueryRecommendations(request.Query)
 	default:
 		return nil, fmt.Errorf("unsupported query type: %s", request.Query.QueryType)
 	}
@@ -1187,3 +1292,330 @@ func computeEfficiencyMetric(alloc *opencost.Allocation, bufferMultiplier float6
 		End:                        alloc.End,
 	}
 }
+
+// QueryRecommendations generates cost optimization recommendations based on allocation data.
+func (s *MCPServer) QueryRecommendations(query *OpenCostQueryRequest) (*RecommendationsResponse, error) {
+	// 1. Parse Window
+	window, err := opencost.ParseWindowWithOffset(query.Window, 0)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse window '%s': %w", query.Window, err)
+	}
+
+	// 2. Set default parameters
+	var aggregateBy []string
+	var filterString string
+	var bufferMultiplier float64 = efficiencyBufferMultiplier
+	var minSavings float64 = minSavingsThreshold
+	includeIdle := true
+	includeOversized := true
+	includeRightsize := true
+	var topN *int
+
+	// 3. Parse recommendations parameters if provided
+	if query.RecommendationsParams != nil {
+		if query.RecommendationsParams.Aggregate != "" {
+			aggregateBy = strings.Split(query.RecommendationsParams.Aggregate, ",")
+		} else {
+			aggregateBy = []string{"pod"}
+		}
+
+		filterString = query.RecommendationsParams.Filter
+		if filterString != "" {
+			parser := allocation.NewAllocationFilterParser()
+			_, err := parser.Parse(filterString)
+			if err != nil {
+				return nil, fmt.Errorf("invalid allocation filter '%s': %w", filterString, err)
+			}
+		}
+
+		if query.RecommendationsParams.BufferMultiplier != nil {
+			bufferMultiplier = *query.RecommendationsParams.BufferMultiplier
+		}
+		if query.RecommendationsParams.MinSavings != nil {
+			minSavings = *query.RecommendationsParams.MinSavings
+		}
+		// Use explicit booleans (default to true if not specified via empty params)
+		includeIdle = query.RecommendationsParams.IncludeIdle || (!query.RecommendationsParams.IncludeIdle && !query.RecommendationsParams.IncludeOversized && !query.RecommendationsParams.IncludeRightsize)
+		includeOversized = query.RecommendationsParams.IncludeOversized || (!query.RecommendationsParams.IncludeIdle && !query.RecommendationsParams.IncludeOversized && !query.RecommendationsParams.IncludeRightsize)
+		includeRightsize = query.RecommendationsParams.IncludeRightsize || (!query.RecommendationsParams.IncludeIdle && !query.RecommendationsParams.IncludeOversized && !query.RecommendationsParams.IncludeRightsize)
+		topN = query.RecommendationsParams.TopN
+	} else {
+		aggregateBy = []string{"pod"}
+	}
+
+	// 4. Query allocations
+	step := window.Duration()
+	asr, err := s.costModel.QueryAllocation(
+		window,
+		step,
+		aggregateBy,
+		false, // includeIdle
+		false, // idleByNode
+		false, // includeProportionalAssetResourceCosts
+		false, // includeAggregatedMetadata
+		false, // sharedLoadBalancer
+		opencost.AccumulateOptionNone,
+		false, // shareIdle
+		filterString,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query allocations: %w", err)
+	}
+
+	// 5. Handle empty results
+	if asr == nil || len(asr.Allocations) == 0 {
+		return &RecommendationsResponse{
+			Recommendations: []*Recommendation{},
+			Summary:         createEmptyRecommendationsSummary(),
+			Window: &TimeWindow{
+				Start: *window.Start(),
+				End:   *window.End(),
+			},
+		}, nil
+	}
+
+	// 6. Generate recommendations from allocations
+	var recommendations []*Recommendation
+	for _, allocSet := range asr.Allocations {
+		if allocSet == nil {
+			continue
+		}
+		for _, alloc := range allocSet.Allocations {
+			recs := generateRecommendationsFromAllocation(alloc, bufferMultiplier, minSavings, includeIdle, includeOversized, includeRightsize)
+			recommendations = append(recommendations, recs...)
+		}
+	}
+
+	// 7. Sort recommendations by estimated savings (highest first)
+	sortRecommendationsBySavings(recommendations)
+
+	// 8. Apply topN limit if specified
+	if topN != nil && *topN > 0 && len(recommendations) > *topN {
+		recommendations = recommendations[:*topN]
+	}
+
+	// 9. Build summary
+	summary := buildRecommendationsSummary(recommendations)
+
+	return &RecommendationsResponse{
+		Recommendations: recommendations,
+		Summary:         summary,
+		Window: &TimeWindow{
+			Start: *window.Start(),
+			End:   *window.End(),
+		},
+	}, nil
+}
+
+// generateRecommendationsFromAllocation creates recommendations for a single allocation.
+func generateRecommendationsFromAllocation(alloc *opencost.Allocation, bufferMultiplier, minSavings float64, includeIdle, includeOversized, includeRightsize bool) []*Recommendation {
+	if alloc == nil {
+		return nil
+	}
+
+	hours := alloc.Minutes() / 60.0
+	if hours <= 0 {
+		return nil
+	}
+
+	var recommendations []*Recommendation
+
+	// Calculate usage metrics
+	cpuCoresUsed := alloc.CPUCoreHours / hours
+	ramBytesUsed := alloc.RAMByteHours / hours
+	cpuCoresRequested := alloc.CPUCoreRequestAverage
+	ramBytesRequested := alloc.RAMBytesRequestAverage
+
+	// Calculate efficiency
+	cpuEfficiency := safeDiv(cpuCoresUsed, cpuCoresRequested)
+	memoryEfficiency := safeDiv(ramBytesUsed, ramBytesRequested)
+
+	// Calculate recommended values
+	recommendedCPU := cpuCoresUsed * bufferMultiplier
+	recommendedRAM := ramBytesUsed * bufferMultiplier
+	if recommendedCPU < efficiencyMinCPU {
+		recommendedCPU = efficiencyMinCPU
+	}
+	if recommendedRAM < efficiencyMinRAM {
+		recommendedRAM = efficiencyMinRAM
+	}
+
+	// Calculate costs
+	cpuCostPerCoreHour := safeDiv(alloc.CPUCost, cpuCoresRequested*hours)
+	ramCostPerByteHour := safeDiv(alloc.RAMCost, ramBytesRequested*hours)
+	currentTotalCost := alloc.TotalCost()
+
+	recommendedCPUCost := recommendedCPU * hours * cpuCostPerCoreHour
+	recommendedRAMCost := recommendedRAM * hours * ramCostPerByteHour
+	otherCosts := alloc.PVCost() + alloc.NetworkCost + alloc.SharedCost + alloc.ExternalCost + alloc.GPUCost
+	recommendedTotalCost := recommendedCPUCost + recommendedRAMCost + otherCosts
+
+	if recommendedTotalCost > currentTotalCost && (recommendedTotalCost-currentTotalCost) < 0.0001 {
+		recommendedTotalCost = currentTotalCost
+	}
+
+	savings := currentTotalCost - recommendedTotalCost
+	savingsPercent := safeDiv(savings, currentTotalCost) * 100
+
+	// Skip if savings are below threshold
+	if savings < minSavings {
+		return nil
+	}
+
+	// Check for idle resources
+	if includeIdle && cpuEfficiency < idleCPUThreshold && memoryEfficiency < idleMemoryThreshold && cpuCoresRequested > 0 && ramBytesRequested > 0 {
+		rec := &Recommendation{
+			ID:                      generateRecommendationID(),
+			Type:                    RecommendationTypeIdle,
+			Priority:                RecommendationPriorityHigh,
+			ResourceName:           alloc.Name,
+			Description:            fmt.Sprintf("Resource '%s' appears to be idle with <%.0f%% CPU and <%.0f%% memory utilization", alloc.Name, idleCPUThreshold*100, idleMemoryThreshold*100),
+			Action:                  "Consider removing or scaling down this resource. Verify it's not needed before deletion.",
+			CurrentCPURequest:       cpuCoresRequested,
+			CurrentRAMRequest:       ramBytesRequested,
+			CurrentCPUUsage:         cpuCoresUsed,
+			CurrentRAMUsage:         ramBytesUsed,
+			CurrentCost:             currentTotalCost,
+			CPUEfficiency:           cpuEfficiency,
+			MemoryEfficiency:        memoryEfficiency,
+			RecommendedCPURequest:   recommendedCPU,
+			RecommendedRAMRequest:   recommendedRAM,
+			RecommendedCost:         recommendedTotalCost,
+			EstimatedSavings:        savings,
+			EstimatedSavingsPercent: savingsPercent,
+			Start:                   alloc.Start,
+			End:                     alloc.End,
+		}
+		recommendations = append(recommendations, rec)
+		return recommendations // Return early, idle is the most critical
+	}
+
+	// Check for oversized resources
+	if includeOversized && (cpuEfficiency < oversizedCPUThreshold || memoryEfficiency < oversizedMemoryThreshold) && cpuCoresRequested > 0 && ramBytesRequested > 0 {
+		priority := RecommendationPriorityMedium
+		if cpuEfficiency < 0.1 || memoryEfficiency < 0.1 {
+			priority = RecommendationPriorityHigh
+		}
+
+		rec := &Recommendation{
+			ID:                      generateRecommendationID(),
+			Type:                    RecommendationTypeOversized,
+			Priority:                priority,
+			ResourceName:           alloc.Name,
+			Description:            fmt.Sprintf("Resource '%s' is significantly oversized with %.1f%% CPU and %.1f%% memory efficiency", alloc.Name, cpuEfficiency*100, memoryEfficiency*100),
+			Action:                  fmt.Sprintf("Reduce CPU request from %.3f to %.3f cores and RAM request from %.0f to %.0f bytes", cpuCoresRequested, recommendedCPU, ramBytesRequested, recommendedRAM),
+			CurrentCPURequest:       cpuCoresRequested,
+			CurrentRAMRequest:       ramBytesRequested,
+			CurrentCPUUsage:         cpuCoresUsed,
+			CurrentRAMUsage:         ramBytesUsed,
+			CurrentCost:             currentTotalCost,
+			CPUEfficiency:           cpuEfficiency,
+			MemoryEfficiency:        memoryEfficiency,
+			RecommendedCPURequest:   recommendedCPU,
+			RecommendedRAMRequest:   recommendedRAM,
+			RecommendedCost:         recommendedTotalCost,
+			EstimatedSavings:        savings,
+			EstimatedSavingsPercent: savingsPercent,
+			Start:                   alloc.Start,
+			End:                     alloc.End,
+		}
+		recommendations = append(recommendations, rec)
+		return recommendations
+	}
+
+	// General rightsizing recommendation
+	if includeRightsize && savings >= minSavings {
+		priority := RecommendationPriorityLow
+		if savingsPercent >= 30 {
+			priority = RecommendationPriorityMedium
+		}
+		if savingsPercent >= 50 {
+			priority = RecommendationPriorityHigh
+		}
+
+		rec := &Recommendation{
+			ID:                      generateRecommendationID(),
+			Type:                    RecommendationTypeRightsize,
+			Priority:                priority,
+			ResourceName:           alloc.Name,
+			Description:            fmt.Sprintf("Resource '%s' can be rightsized for %.1f%% cost savings", alloc.Name, savingsPercent),
+			Action:                  fmt.Sprintf("Adjust CPU request to %.3f cores (from %.3f) and RAM request to %.0f bytes (from %.0f)", recommendedCPU, cpuCoresRequested, recommendedRAM, ramBytesRequested),
+			CurrentCPURequest:       cpuCoresRequested,
+			CurrentRAMRequest:       ramBytesRequested,
+			CurrentCPUUsage:         cpuCoresUsed,
+			CurrentRAMUsage:         ramBytesUsed,
+			CurrentCost:             currentTotalCost,
+			CPUEfficiency:           cpuEfficiency,
+			MemoryEfficiency:        memoryEfficiency,
+			RecommendedCPURequest:   recommendedCPU,
+			RecommendedRAMRequest:   recommendedRAM,
+			RecommendedCost:         recommendedTotalCost,
+			EstimatedSavings:        savings,
+			EstimatedSavingsPercent: savingsPercent,
+			Start:                   alloc.Start,
+			End:                     alloc.End,
+		}
+		recommendations = append(recommendations, rec)
+	}
+
+	return recommendations
+}
+
+// generateRecommendationID creates a unique ID for a recommendation.
+func generateRecommendationID() string {
+	bytes := make([]byte, 8)
+	if _, err := rand.Read(bytes); err != nil {
+		return fmt.Sprintf("rec-%d", time.Now().UnixNano())
+	}
+	return fmt.Sprintf("rec-%s", hex.EncodeToString(bytes))
+}
+
+// sortRecommendationsBySavings sorts recommendations by estimated savings in descending order.
+func sortRecommendationsBySavings(recommendations []*Recommendation) {
+	for i := 0; i < len(recommendations); i++ {
+		for j := i + 1; j < len(recommendations); j++ {
+			if recommendations[j].EstimatedSavings > recommendations[i].EstimatedSavings {
+				recommendations[i], recommendations[j] = recommendations[j], recommendations[i]
+			}
+		}
+	}
+}
+
+// createEmptyRecommendationsSummary creates an empty summary for when there are no recommendations.
+func createEmptyRecommendationsSummary() *RecommendationsSummary {
+	return &RecommendationsSummary{
+		TotalRecommendations:  0,
+		TotalPotentialSavings: 0,
+		ByType:                make(map[string]int),
+		ByPriority:            make(map[string]int),
+		IdleResourceCount:     0,
+		OversizedCount:        0,
+		RightsizeCount:        0,
+	}
+}
+
+// buildRecommendationsSummary creates a summary from a list of recommendations.
+func buildRecommendationsSummary(recommendations []*Recommendation) *RecommendationsSummary {
+	summary := &RecommendationsSummary{
+		TotalRecommendations: len(recommendations),
+		ByType:               make(map[string]int),
+		ByPriority:           make(map[string]int),
+	}
+
+	for _, rec := range recommendations {
+		summary.TotalPotentialSavings += rec.EstimatedSavings
+		summary.ByType[string(rec.Type)]++
+		summary.ByPriority[string(rec.Priority)]++
+
+		switch rec.Type {
+		case RecommendationTypeIdle:
+			summary.IdleResourceCount++
+		case RecommendationTypeOversized:
+			summary.OversizedCount++
+		case RecommendationTypeRightsize:
+			summary.RightsizeCount++
+		}
+	}
+
+	return summary
+}

+ 489 - 0
pkg/mcp/server_test.go

@@ -1359,3 +1359,492 @@ func TestEfficiencyConstants(t *testing.T) {
 func TestEfficiencyQueryType(t *testing.T) {
 	assert.Equal(t, QueryType("efficiency"), EfficiencyQueryType)
 }
+
+// ---- Tests for Recommendations Tool ----
+
+func TestRecommendationsQueryType(t *testing.T) {
+	assert.Equal(t, QueryType("recommendations"), RecommendationsQueryType)
+}
+
+func TestRecommendationTypeConstants(t *testing.T) {
+	assert.Equal(t, RecommendationType("idle"), RecommendationTypeIdle)
+	assert.Equal(t, RecommendationType("oversized"), RecommendationTypeOversized)
+	assert.Equal(t, RecommendationType("rightsize"), RecommendationTypeRightsize)
+	assert.Equal(t, RecommendationType("underprovisioned"), RecommendationTypeUnderprovisioned)
+}
+
+func TestRecommendationPriorityConstants(t *testing.T) {
+	assert.Equal(t, RecommendationPriority("high"), RecommendationPriorityHigh)
+	assert.Equal(t, RecommendationPriority("medium"), RecommendationPriorityMedium)
+	assert.Equal(t, RecommendationPriority("low"), RecommendationPriorityLow)
+}
+
+func TestRecommendationThresholds(t *testing.T) {
+	assert.Equal(t, 0.05, idleCPUThreshold)
+	assert.Equal(t, 0.05, idleMemoryThreshold)
+	assert.Equal(t, 0.3, oversizedCPUThreshold)
+	assert.Equal(t, 0.3, oversizedMemoryThreshold)
+	assert.Equal(t, 0.9, underprovisionedCPUThreshold)
+	assert.Equal(t, 0.9, underprovisionedMemoryThreshold)
+	assert.Equal(t, 0.01, minSavingsThreshold)
+}
+
+func TestRecommendationsQueryStruct(t *testing.T) {
+	bufferMultiplier := 1.4
+	minSavings := 1.0
+	topN := 10
+	query := RecommendationsQuery{
+		Aggregate:        "pod",
+		Filter:           "namespace:production",
+		BufferMultiplier: &bufferMultiplier,
+		MinSavings:       &minSavings,
+		IncludeIdle:      true,
+		IncludeOversized: true,
+		IncludeRightsize: true,
+		TopN:             &topN,
+	}
+
+	assert.Equal(t, "pod", query.Aggregate)
+	assert.Equal(t, "namespace:production", query.Filter)
+	assert.NotNil(t, query.BufferMultiplier)
+	assert.Equal(t, 1.4, *query.BufferMultiplier)
+	assert.NotNil(t, query.MinSavings)
+	assert.Equal(t, 1.0, *query.MinSavings)
+	assert.True(t, query.IncludeIdle)
+	assert.True(t, query.IncludeOversized)
+	assert.True(t, query.IncludeRightsize)
+	assert.NotNil(t, query.TopN)
+	assert.Equal(t, 10, *query.TopN)
+}
+
+func TestRecommendationsQueryDefaultValues(t *testing.T) {
+	query := RecommendationsQuery{}
+
+	assert.Empty(t, query.Aggregate)
+	assert.Empty(t, query.Filter)
+	assert.Nil(t, query.BufferMultiplier)
+	assert.Nil(t, query.MinSavings)
+	assert.False(t, query.IncludeIdle)
+	assert.False(t, query.IncludeOversized)
+	assert.False(t, query.IncludeRightsize)
+	assert.Nil(t, query.TopN)
+}
+
+func TestRecommendationStruct(t *testing.T) {
+	now := time.Now()
+	rec := Recommendation{
+		ID:                      "rec-123",
+		Type:                    RecommendationTypeOversized,
+		Priority:                RecommendationPriorityHigh,
+		ResourceName:           "test-pod",
+		Description:            "Resource is oversized",
+		Action:                  "Reduce CPU request",
+		CurrentCPURequest:       2.0,
+		CurrentRAMRequest:       2147483648,
+		CurrentCPUUsage:         0.5,
+		CurrentRAMUsage:         536870912,
+		CurrentCost:             10.0,
+		CPUEfficiency:           0.25,
+		MemoryEfficiency:        0.25,
+		RecommendedCPURequest:   0.6,
+		RecommendedRAMRequest:   644245094,
+		RecommendedCost:         3.0,
+		EstimatedSavings:        7.0,
+		EstimatedSavingsPercent: 70.0,
+		Start:                   now.Add(-24 * time.Hour),
+		End:                     now,
+	}
+
+	assert.Equal(t, "rec-123", rec.ID)
+	assert.Equal(t, RecommendationTypeOversized, rec.Type)
+	assert.Equal(t, RecommendationPriorityHigh, rec.Priority)
+	assert.Equal(t, "test-pod", rec.ResourceName)
+	assert.Equal(t, "Resource is oversized", rec.Description)
+	assert.Equal(t, "Reduce CPU request", rec.Action)
+	assert.Equal(t, 2.0, rec.CurrentCPURequest)
+	assert.Equal(t, 2147483648.0, rec.CurrentRAMRequest)
+	assert.Equal(t, 0.5, rec.CurrentCPUUsage)
+	assert.Equal(t, 536870912.0, rec.CurrentRAMUsage)
+	assert.Equal(t, 10.0, rec.CurrentCost)
+	assert.Equal(t, 0.25, rec.CPUEfficiency)
+	assert.Equal(t, 0.25, rec.MemoryEfficiency)
+	assert.Equal(t, 0.6, rec.RecommendedCPURequest)
+	assert.Equal(t, 644245094.0, rec.RecommendedRAMRequest)
+	assert.Equal(t, 3.0, rec.RecommendedCost)
+	assert.Equal(t, 7.0, rec.EstimatedSavings)
+	assert.Equal(t, 70.0, rec.EstimatedSavingsPercent)
+	assert.True(t, rec.Start.Before(rec.End))
+}
+
+func TestRecommendationsSummaryStruct(t *testing.T) {
+	summary := RecommendationsSummary{
+		TotalRecommendations:  10,
+		TotalPotentialSavings: 100.0,
+		ByType: map[string]int{
+			"idle":      2,
+			"oversized": 5,
+			"rightsize": 3,
+		},
+		ByPriority: map[string]int{
+			"high":   3,
+			"medium": 4,
+			"low":    3,
+		},
+		IdleResourceCount: 2,
+		OversizedCount:    5,
+		RightsizeCount:    3,
+	}
+
+	assert.Equal(t, 10, summary.TotalRecommendations)
+	assert.Equal(t, 100.0, summary.TotalPotentialSavings)
+	assert.Equal(t, 2, summary.ByType["idle"])
+	assert.Equal(t, 5, summary.ByType["oversized"])
+	assert.Equal(t, 3, summary.ByType["rightsize"])
+	assert.Equal(t, 3, summary.ByPriority["high"])
+	assert.Equal(t, 4, summary.ByPriority["medium"])
+	assert.Equal(t, 3, summary.ByPriority["low"])
+	assert.Equal(t, 2, summary.IdleResourceCount)
+	assert.Equal(t, 5, summary.OversizedCount)
+	assert.Equal(t, 3, summary.RightsizeCount)
+}
+
+func TestRecommendationsResponseStruct(t *testing.T) {
+	now := time.Now()
+	rec1 := &Recommendation{
+		ID:               "rec-1",
+		Type:             RecommendationTypeIdle,
+		Priority:         RecommendationPriorityHigh,
+		ResourceName:    "idle-pod",
+		EstimatedSavings: 50.0,
+		Start:            now.Add(-24 * time.Hour),
+		End:              now,
+	}
+	rec2 := &Recommendation{
+		ID:               "rec-2",
+		Type:             RecommendationTypeOversized,
+		Priority:         RecommendationPriorityMedium,
+		ResourceName:    "big-pod",
+		EstimatedSavings: 25.0,
+		Start:            now.Add(-24 * time.Hour),
+		End:              now,
+	}
+
+	response := RecommendationsResponse{
+		Recommendations: []*Recommendation{rec1, rec2},
+		Summary: &RecommendationsSummary{
+			TotalRecommendations:  2,
+			TotalPotentialSavings: 75.0,
+			ByType: map[string]int{
+				"idle":      1,
+				"oversized": 1,
+			},
+			ByPriority: map[string]int{
+				"high":   1,
+				"medium": 1,
+			},
+			IdleResourceCount: 1,
+			OversizedCount:    1,
+		},
+		Window: &TimeWindow{
+			Start: now.Add(-24 * time.Hour),
+			End:   now,
+		},
+	}
+
+	require.NotNil(t, response.Recommendations)
+	assert.Len(t, response.Recommendations, 2)
+	assert.Equal(t, "rec-1", response.Recommendations[0].ID)
+	assert.Equal(t, "rec-2", response.Recommendations[1].ID)
+	require.NotNil(t, response.Summary)
+	assert.Equal(t, 2, response.Summary.TotalRecommendations)
+	assert.Equal(t, 75.0, response.Summary.TotalPotentialSavings)
+	require.NotNil(t, response.Window)
+	assert.True(t, response.Window.Start.Before(response.Window.End))
+}
+
+func TestGenerateRecommendationID(t *testing.T) {
+	id1 := generateRecommendationID()
+	id2 := generateRecommendationID()
+
+	assert.NotEmpty(t, id1)
+	assert.NotEmpty(t, id2)
+	assert.NotEqual(t, id1, id2)
+	assert.Contains(t, id1, "rec-")
+}
+
+func TestSortRecommendationsBySavings(t *testing.T) {
+	recs := []*Recommendation{
+		{ID: "low", EstimatedSavings: 10.0},
+		{ID: "high", EstimatedSavings: 100.0},
+		{ID: "medium", EstimatedSavings: 50.0},
+	}
+
+	sortRecommendationsBySavings(recs)
+
+	assert.Equal(t, "high", recs[0].ID)
+	assert.Equal(t, 100.0, recs[0].EstimatedSavings)
+	assert.Equal(t, "medium", recs[1].ID)
+	assert.Equal(t, 50.0, recs[1].EstimatedSavings)
+	assert.Equal(t, "low", recs[2].ID)
+	assert.Equal(t, 10.0, recs[2].EstimatedSavings)
+}
+
+func TestSortRecommendationsBySavings_Empty(t *testing.T) {
+	recs := []*Recommendation{}
+	sortRecommendationsBySavings(recs)
+	assert.Empty(t, recs)
+}
+
+func TestSortRecommendationsBySavings_Single(t *testing.T) {
+	recs := []*Recommendation{
+		{ID: "only", EstimatedSavings: 50.0},
+	}
+	sortRecommendationsBySavings(recs)
+	assert.Len(t, recs, 1)
+	assert.Equal(t, "only", recs[0].ID)
+}
+
+func TestCreateEmptyRecommendationsSummary(t *testing.T) {
+	summary := createEmptyRecommendationsSummary()
+
+	require.NotNil(t, summary)
+	assert.Equal(t, 0, summary.TotalRecommendations)
+	assert.Equal(t, 0.0, summary.TotalPotentialSavings)
+	assert.NotNil(t, summary.ByType)
+	assert.NotNil(t, summary.ByPriority)
+	assert.Equal(t, 0, summary.IdleResourceCount)
+	assert.Equal(t, 0, summary.OversizedCount)
+	assert.Equal(t, 0, summary.RightsizeCount)
+}
+
+func TestBuildRecommendationsSummary(t *testing.T) {
+	recs := []*Recommendation{
+		{Type: RecommendationTypeIdle, Priority: RecommendationPriorityHigh, EstimatedSavings: 50.0},
+		{Type: RecommendationTypeOversized, Priority: RecommendationPriorityMedium, EstimatedSavings: 30.0},
+		{Type: RecommendationTypeOversized, Priority: RecommendationPriorityHigh, EstimatedSavings: 25.0},
+		{Type: RecommendationTypeRightsize, Priority: RecommendationPriorityLow, EstimatedSavings: 10.0},
+	}
+
+	summary := buildRecommendationsSummary(recs)
+
+	require.NotNil(t, summary)
+	assert.Equal(t, 4, summary.TotalRecommendations)
+	assert.Equal(t, 115.0, summary.TotalPotentialSavings)
+	assert.Equal(t, 1, summary.ByType[string(RecommendationTypeIdle)])
+	assert.Equal(t, 2, summary.ByType[string(RecommendationTypeOversized)])
+	assert.Equal(t, 1, summary.ByType[string(RecommendationTypeRightsize)])
+	assert.Equal(t, 2, summary.ByPriority[string(RecommendationPriorityHigh)])
+	assert.Equal(t, 1, summary.ByPriority[string(RecommendationPriorityMedium)])
+	assert.Equal(t, 1, summary.ByPriority[string(RecommendationPriorityLow)])
+	assert.Equal(t, 1, summary.IdleResourceCount)
+	assert.Equal(t, 2, summary.OversizedCount)
+	assert.Equal(t, 1, summary.RightsizeCount)
+}
+
+func TestBuildRecommendationsSummary_Empty(t *testing.T) {
+	recs := []*Recommendation{}
+
+	summary := buildRecommendationsSummary(recs)
+
+	require.NotNil(t, summary)
+	assert.Equal(t, 0, summary.TotalRecommendations)
+	assert.Equal(t, 0.0, summary.TotalPotentialSavings)
+}
+
+func TestGenerateRecommendationsFromAllocation_NilAllocation(t *testing.T) {
+	recs := generateRecommendationsFromAllocation(nil, 1.2, 0.01, true, true, true)
+	assert.Nil(t, recs)
+}
+
+func TestGenerateRecommendationsFromAllocation_ZeroMinutes(t *testing.T) {
+	now := time.Now()
+	alloc := &opencost.Allocation{
+		Name:  "test-pod",
+		Start: now,
+		End:   now,
+	}
+
+	recs := generateRecommendationsFromAllocation(alloc, 1.2, 0.01, true, true, true)
+	assert.Nil(t, recs)
+}
+
+func TestGenerateRecommendationsFromAllocation_IdleResource(t *testing.T) {
+	now := time.Now()
+	alloc := &opencost.Allocation{
+		Name:                   "idle-pod",
+		Start:                  now.Add(-24 * time.Hour),
+		End:                    now,
+		CPUCoreHours:           0.24, // Very low usage: 0.01 cores average
+		RAMByteHours:           24e6, // Very low usage: ~1MB average
+		CPUCoreRequestAverage:  2.0,
+		RAMBytesRequestAverage: 2e9,
+		CPUCost:                10.0,
+		RAMCost:                5.0,
+	}
+
+	recs := generateRecommendationsFromAllocation(alloc, 1.2, 0.01, true, true, true)
+
+	require.NotNil(t, recs)
+	require.Len(t, recs, 1)
+	assert.Equal(t, RecommendationTypeIdle, recs[0].Type)
+	assert.Equal(t, RecommendationPriorityHigh, recs[0].Priority)
+	assert.Equal(t, "idle-pod", recs[0].ResourceName)
+}
+
+func TestGenerateRecommendationsFromAllocation_OversizedResource(t *testing.T) {
+	now := time.Now()
+	alloc := &opencost.Allocation{
+		Name:                   "oversized-pod",
+		Start:                  now.Add(-24 * time.Hour),
+		End:                    now,
+		CPUCoreHours:           12.0,   // 0.5 cores average (25% of requested)
+		RAMByteHours:           12.0e9, // 0.5GB average (25% of requested)
+		CPUCoreRequestAverage:  2.0,
+		RAMBytesRequestAverage: 2.0e9,
+		CPUCost:                10.0,
+		RAMCost:                5.0,
+	}
+
+	recs := generateRecommendationsFromAllocation(alloc, 1.2, 0.01, true, true, true)
+
+	require.NotNil(t, recs)
+	require.Len(t, recs, 1)
+	assert.Equal(t, RecommendationTypeOversized, recs[0].Type)
+	assert.Equal(t, "oversized-pod", recs[0].ResourceName)
+}
+
+func TestGenerateRecommendationsFromAllocation_RightsizeResource(t *testing.T) {
+	now := time.Now()
+	alloc := &opencost.Allocation{
+		Name:                   "rightsize-pod",
+		Start:                  now.Add(-24 * time.Hour),
+		End:                    now,
+		CPUCoreHours:           19.2,    // 0.8 cores average (40% of requested)
+		RAMByteHours:           19.2e9,  // 0.8GB average (40% of requested)
+		CPUCoreRequestAverage:  2.0,
+		RAMBytesRequestAverage: 2.0e9,
+		CPUCost:                10.0,
+		RAMCost:                5.0,
+	}
+
+	recs := generateRecommendationsFromAllocation(alloc, 1.2, 0.01, false, false, true)
+
+	require.NotNil(t, recs)
+	require.Len(t, recs, 1)
+	assert.Equal(t, RecommendationTypeRightsize, recs[0].Type)
+	assert.Equal(t, "rightsize-pod", recs[0].ResourceName)
+}
+
+func TestGenerateRecommendationsFromAllocation_NoSavings(t *testing.T) {
+	now := time.Now()
+	alloc := &opencost.Allocation{
+		Name:                   "efficient-pod",
+		Start:                  now.Add(-24 * time.Hour),
+		End:                    now,
+		CPUCoreHours:           48.0,   // 2 cores average - over 100% of requested
+		RAMByteHours:           48.0e9, // 2GB average - over 100% of requested
+		CPUCoreRequestAverage:  2.0,
+		RAMBytesRequestAverage: 2.0e9,
+		CPUCost:                10.0,
+		RAMCost:                5.0,
+	}
+
+	recs := generateRecommendationsFromAllocation(alloc, 1.2, 0.01, true, true, true)
+
+	// No savings expected since usage exceeds requests
+	assert.Nil(t, recs)
+}
+
+func TestGenerateRecommendationsFromAllocation_BelowMinSavings(t *testing.T) {
+	now := time.Now()
+	alloc := &opencost.Allocation{
+		Name:                   "small-savings-pod",
+		Start:                  now.Add(-24 * time.Hour),
+		End:                    now,
+		CPUCoreHours:           23.5,   // Close to requested
+		RAMByteHours:           23.5e9, // Close to requested
+		CPUCoreRequestAverage:  1.0,
+		RAMBytesRequestAverage: 1.0e9,
+		CPUCost:                0.001, // Very small costs
+		RAMCost:                0.001,
+	}
+
+	recs := generateRecommendationsFromAllocation(alloc, 1.2, 1.0, true, true, true) // High min savings threshold
+
+	assert.Nil(t, recs)
+}
+
+func TestGenerateRecommendationsFromAllocation_DisabledCategories(t *testing.T) {
+	now := time.Now()
+	alloc := &opencost.Allocation{
+		Name:                   "test-pod",
+		Start:                  now.Add(-24 * time.Hour),
+		End:                    now,
+		CPUCoreHours:           0.24, // Idle-level usage
+		RAMByteHours:           24e6,
+		CPUCoreRequestAverage:  2.0,
+		RAMBytesRequestAverage: 2e9,
+		CPUCost:                10.0,
+		RAMCost:                5.0,
+	}
+
+	// Disable all categories
+	recs := generateRecommendationsFromAllocation(alloc, 1.2, 0.01, false, false, false)
+	assert.Nil(t, recs)
+}
+
+func TestQueryRecommendations_InvalidWindow(t *testing.T) {
+	s := &MCPServer{}
+
+	req := &OpenCostQueryRequest{
+		QueryType: RecommendationsQueryType,
+		Window:    "invalid-window",
+	}
+
+	_, err := s.QueryRecommendations(req)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "failed to parse window")
+}
+
+func TestQueryRecommendations_WithTopN(t *testing.T) {
+	topN := 5
+	req := &OpenCostQueryRequest{
+		QueryType: RecommendationsQueryType,
+		Window:    "7d",
+		RecommendationsParams: &RecommendationsQuery{
+			TopN: &topN,
+		},
+	}
+
+	assert.NotNil(t, req.RecommendationsParams.TopN)
+	assert.Equal(t, 5, *req.RecommendationsParams.TopN)
+}
+
+func TestQueryRecommendations_WithFilter(t *testing.T) {
+	req := &OpenCostQueryRequest{
+		QueryType: RecommendationsQueryType,
+		Window:    "7d",
+		RecommendationsParams: &RecommendationsQuery{
+			Aggregate: "pod",
+			Filter:    "namespace:production",
+		},
+	}
+
+	assert.Equal(t, "pod", req.RecommendationsParams.Aggregate)
+	assert.Equal(t, "namespace:production", req.RecommendationsParams.Filter)
+}
+
+func TestOpenCostQueryRequest_IncludesRecommendations(t *testing.T) {
+	req := OpenCostQueryRequest{
+		QueryType: RecommendationsQueryType,
+		Window:    "7d",
+		RecommendationsParams: &RecommendationsQuery{
+			Aggregate: "namespace",
+		},
+	}
+
+	assert.Equal(t, RecommendationsQueryType, req.QueryType)
+	assert.Equal(t, "7d", req.Window)
+	assert.NotNil(t, req.RecommendationsParams)
+	assert.Equal(t, "namespace", req.RecommendationsParams.Aggregate)
+}