diff --git a/pkg/env/env.go b/pkg/env/env.go index b8e39be..676ba2d 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -9,6 +9,7 @@ import ( ) const defaultURL = "http://localhost:9003" +const defaultMCPURL = "http://localhost:8081" const defaultApproxThreshold = 0.0001 // 0.01% const defaultOracleBillingURL = "https://apexapps.oracle.com/" @@ -57,6 +58,16 @@ func GetApproxThreshold() float64 { return approxThreshold } +func GetMCPURL() string { + url := defaultMCPURL + + if os.Getenv("OPENCOST_MCP_URL") != "" { + url = os.Getenv("OPENCOST_MCP_URL") + } + + return strings.TrimRight(url, "/") +} + func GetShowDiff() bool { value := os.Getenv("SHOW_DIFF") if value != "" { diff --git a/test/integration/mcp/allocation_mcp_vs_http_test.go b/test/integration/mcp/allocation_mcp_vs_http_test.go new file mode 100644 index 0000000..af2f96f --- /dev/null +++ b/test/integration/mcp/allocation_mcp_vs_http_test.go @@ -0,0 +1,436 @@ +package mcp + +// Integration tests to compare MCP get_allocation_costs tool results with HTTP API /allocation endpoint results +// This ensures that the MCP tool returns the same data as the HTTP API for allocation queries +// +// Note: All tests use historical time windows (yesterday and earlier) to ensure consistent, +// reproducible results that don't change as new data arrives in the present. + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + "github.com/opencost/opencost-integration-tests/pkg/api" +) + +// MCPAllocationRequest represents the request structure for get_allocation_costs MCP tool +type MCPAllocationRequest struct { + Window string `json:"window"` + Aggregate string `json:"aggregate"` + Step string `json:"step,omitempty"` + Accumulate bool `json:"accumulate,omitempty"` + ShareIdle bool `json:"share_idle,omitempty"` + IncludeIdle bool `json:"include_idle,omitempty"` + IdleByNode bool `json:"idle_by_node,omitempty"` + IncludeProportionalAssetResourceCosts bool `json:"include_proportional_asset_resource_costs,omitempty"` + IncludeAggregatedMetadata bool `json:"include_aggregated_metadata,omitempty"` + ShareLB bool `json:"share_lb,omitempty"` + Filter string `json:"filter,omitempty"` +} + +// MCPToolRequest wraps the tool call in MCP format (JSON-RPC 2.0) +type MCPToolRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + ID int `json:"id"` + Params struct { + Name string `json:"name"` + Arguments MCPAllocationRequest `json:"arguments"` + } `json:"params"` +} + +// MCPAllocationData matches the MCP tool response format +type MCPAllocationData struct { + Data struct { + Allocations map[string]struct { + Name string `json:"name"` + Allocations []struct { + Name string `json:"name"` + CPUCost float64 `json:"cpuCost"` + GPUCost float64 `json:"gpuCost"` + RAMCost float64 `json:"ramCost"` + PVCost float64 `json:"pvCost"` + NetworkCost float64 `json:"networkCost"` + SharedCost float64 `json:"sharedCost"` + ExternalCost float64 `json:"externalCost"` + TotalCost float64 `json:"totalCost"` + CPUCoreHours float64 `json:"cpuCoreHours"` + RAMByteHours float64 `json:"ramByteHours"` + GPUHours float64 `json:"gpuHours"` + PVByteHours float64 `json:"pvByteHours"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + } `json:"allocations"` + } `json:"allocations"` + } `json:"data"` + QueryInfo struct { + QueryID string `json:"queryId"` + Timestamp string `json:"timestamp"` + ProcessingTime float64 `json:"processingTime"` + } `json:"queryInfo"` +} + +// callMCPTool calls the MCP get_allocation_costs tool +func callMCPTool(req MCPAllocationRequest) (*MCPAllocationData, error) { + // Initialize session first + sessionID, err := initializeMCPSession() + if err != nil { + return nil, fmt.Errorf("failed to initialize MCP session: %w", err) + } + + mcpReq := MCPToolRequest{ + JSONRPC: "2.0", + Method: "tools/call", + ID: 1, + } + mcpReq.Params.Name = "get_allocation_costs" + mcpReq.Params.Arguments = req + + jsonData, err := json.Marshal(mcpReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal MCP request: %w", err) + } + + url := getMCPURL() + "/mcp" + + // Create request with proper headers for MCP server + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + httpReq.Header.Set("Mcp-Session-Id", sessionID) + + client := &http.Client{} + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to call MCP tool: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("MCP tool returned status %d: %s", resp.StatusCode, string(body)) + } + + var mcpResp MCPResponse + if err := json.NewDecoder(resp.Body).Decode(&mcpResp); err != nil { + return nil, fmt.Errorf("failed to decode MCP response: %w", err) + } + + // Check for errors + if mcpResp.Error != nil { + return nil, fmt.Errorf("MCP error (code %d): %s", mcpResp.Error.Code, mcpResp.Error.Message) + } + + // Extract the actual data from the content array + if len(mcpResp.Result.Content) == 0 { + return nil, fmt.Errorf("empty content in MCP response") + } + + // The text field contains a JSON string, so we need to unmarshal it twice + // First unmarshal to get the string, then unmarshal the string to get the data + var textStr string + if err := json.Unmarshal(mcpResp.Result.Content[0].Text, &textStr); err != nil { + // If it's not a string, try unmarshaling directly + var mcpData MCPAllocationData + if err2 := json.Unmarshal(mcpResp.Result.Content[0].Text, &mcpData); err2 != nil { + return nil, fmt.Errorf("failed to decode MCP allocation data (tried both string and direct): %w, %w", err, err2) + } + return &mcpData, nil + } + + // Now unmarshal the JSON string into the actual data structure + var mcpData MCPAllocationData + if err := json.Unmarshal([]byte(textStr), &mcpData); err != nil { + return nil, fmt.Errorf("failed to decode MCP allocation data from string: %w", err) + } + + return &mcpData, nil +} + +// TestMCPAllocationVsHTTP compares MCP tool results with HTTP API results +// Uses only historical time windows (excluding present) to ensure consistent results +func TestMCPAllocationVsHTTP(t *testing.T) { + apiObj := api.NewAPI() + + // Generate historical time windows (yesterday and earlier) + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + twoDaysAgo := now.AddDate(0, 0, -2) + threeDaysAgo := now.AddDate(0, 0, -3) + sevenDaysAgo := now.AddDate(0, 0, -7) + + testCases := []struct { + name string + window string + aggregate string + accumulate bool + filter string + }{ + { + name: "Yesterday namespace aggregation", + window: "yesterday", + aggregate: "namespace", + accumulate: false, + }, + { + name: "2 days ago cluster aggregation", + window: fmt.Sprintf("%s,%s", twoDaysAgo.Format("2006-01-02T15:04:05Z"), yesterday.Format("2006-01-02T15:04:05Z")), + aggregate: "cluster", + accumulate: false, + }, + { + name: "Last 3 days (historical) pod aggregation", + window: fmt.Sprintf("%s,%s", threeDaysAgo.Format("2006-01-02T15:04:05Z"), yesterday.Format("2006-01-02T15:04:05Z")), + aggregate: "pod", + accumulate: false, + }, + { + name: "Yesterday namespace with accumulate", + window: "yesterday", + aggregate: "namespace", + accumulate: true, + }, + { + name: "Last 7 days (historical) controller aggregation", + window: fmt.Sprintf("%s,%s", sevenDaysAgo.Format("2006-01-02T15:04:05Z"), yesterday.Format("2006-01-02T15:04:05Z")), + aggregate: "controller", + accumulate: false, + }, + { + name: "Last week (7-14 days ago) namespace aggregation", + window: fmt.Sprintf("%s,%s", now.AddDate(0, 0, -14).Format("2006-01-02T15:04:05Z"), sevenDaysAgo.Format("2006-01-02T15:04:05Z")), + aggregate: "namespace", + accumulate: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Get HTTP API response + httpResp, err := apiObj.GetAllocation(api.AllocationRequest{ + Window: tc.window, + Aggregate: tc.aggregate, + Accumulate: fmt.Sprintf("%t", tc.accumulate), + Filter: tc.filter, + }) + if err != nil { + t.Fatalf("Failed to get HTTP API response: %v", err) + } + + if httpResp.Code != 200 { + t.Fatalf("HTTP API returned non-200 code: %d", httpResp.Code) + } + + // Get MCP tool response + mcpResp, err := callMCPTool(MCPAllocationRequest{ + Window: tc.window, + Aggregate: tc.aggregate, + Accumulate: tc.accumulate, + Filter: tc.filter, + }) + if err != nil { + t.Fatalf("Failed to get MCP tool response: %v", err) + } + + // Compare results + compareMCPWithHTTP(t, mcpResp, httpResp) + }) + } +} + +// compareMCPWithHTTP compares MCP data with HTTP API data +func compareMCPWithHTTP(t *testing.T, mcpData *MCPAllocationData, httpData *api.AllocationResponse) { + // Get the allocations from MCP response + mcpAllocations := mcpData.Data.Allocations["allocations"] + if len(mcpAllocations.Allocations) == 0 { + t.Log("Warning: MCP response has no allocations") + } + + // HTTP API returns array of maps, MCP returns single allocation set + if len(httpData.Data) == 0 { + t.Log("Warning: HTTP response has no data") + } + + // Build maps for comparison + mcpMap := make(map[string]float64) + for _, alloc := range mcpAllocations.Allocations { + mcpMap[alloc.Name] = alloc.TotalCost + } + + httpMap := make(map[string]float64) + for _, dataMap := range httpData.Data { + for name, item := range dataMap { + httpMap[name] = item.TotalCost + } + } + + // Compare allocation names + for name := range mcpMap { + if _, exists := httpMap[name]; !exists { + t.Errorf("Allocation '%s' exists in MCP but not in HTTP API", name) + } + } + + for name := range httpMap { + if _, exists := mcpMap[name]; !exists { + t.Errorf("Allocation '%s' exists in HTTP API but not in MCP", name) + } + } + + // Compare costs with tolerance for floating point differences + const tolerance = 0.01 + for name, mcpCost := range mcpMap { + if httpCost, exists := httpMap[name]; exists { + diff := abs(mcpCost - httpCost) + percentDiff := 0.0 + if httpCost != 0 { + percentDiff = (diff / httpCost) * 100 + } + + if diff > tolerance && percentDiff > 0.1 { + t.Errorf("Cost mismatch for '%s': MCP=%.4f, HTTP=%.4f (diff=%.4f, %.2f%%)", + name, mcpCost, httpCost, diff, percentDiff) + } else { + t.Logf("Cost match for '%s': MCP=%.4f, HTTP=%.4f", name, mcpCost, httpCost) + } + } + } + + // Log summary + t.Logf("Comparison complete: %d MCP allocations, %d HTTP allocations", + len(mcpMap), len(httpMap)) +} + +// TestMCPAllocationWithFilters tests MCP allocation queries with various filters +// Uses only historical time windows (excluding present) to ensure consistent results +func TestMCPAllocationWithFilters(t *testing.T) { + apiObj := api.NewAPI() + + testCases := []struct { + name string + window string + aggregate string + filter string + }{ + { + name: "Filter by namespace (yesterday)", + window: "yesterday", + aggregate: "pod", + filter: "namespace:\"kube-system\"", + }, + { + name: "Filter by cluster (yesterday)", + window: "yesterday", + aggregate: "namespace", + filter: "cluster:\"tilt-cluster\"", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Get HTTP API response + httpResp, err := apiObj.GetAllocation(api.AllocationRequest{ + Window: tc.window, + Aggregate: tc.aggregate, + Filter: tc.filter, + }) + if err != nil { + t.Fatalf("Failed to get HTTP API response: %v", err) + } + + if httpResp.Code != 200 { + t.Fatalf("HTTP API returned non-200 code: %d", httpResp.Code) + } + + // Get MCP tool response + mcpResp, err := callMCPTool(MCPAllocationRequest{ + Window: tc.window, + Aggregate: tc.aggregate, + Filter: tc.filter, + }) + if err != nil { + t.Fatalf("Failed to get MCP tool response: %v", err) + } + + // Compare results + compareMCPWithHTTP(t, mcpResp, httpResp) + }) + } +} + +// TestMCPAllocationWithIdleAndShare tests MCP allocation queries with idle and share options +// Uses only historical time windows (excluding present) to ensure consistent results +func TestMCPAllocationWithIdleAndShare(t *testing.T) { + apiObj := api.NewAPI() + + testCases := []struct { + name string + window string + aggregate string + includeIdle bool + shareIdle bool + }{ + { + name: "Include idle (yesterday)", + window: "yesterday", + aggregate: "namespace", + includeIdle: true, + shareIdle: false, + }, + { + name: "Share idle (yesterday)", + window: "yesterday", + aggregate: "pod", + includeIdle: false, + shareIdle: true, + }, + { + name: "Include and share idle (yesterday)", + window: "yesterday", + aggregate: "namespace", + includeIdle: true, + shareIdle: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Get HTTP API response + httpResp, err := apiObj.GetAllocation(api.AllocationRequest{ + Window: tc.window, + Aggregate: tc.aggregate, + IncludeIdle: fmt.Sprintf("%t", tc.includeIdle), + ShareIdle: fmt.Sprintf("%t", tc.shareIdle), + }) + if err != nil { + t.Fatalf("Failed to get HTTP API response: %v", err) + } + + if httpResp.Code != 200 { + t.Fatalf("HTTP API returned non-200 code: %d", httpResp.Code) + } + + // Get MCP tool response + mcpResp, err := callMCPTool(MCPAllocationRequest{ + Window: tc.window, + Aggregate: tc.aggregate, + IncludeIdle: tc.includeIdle, + ShareIdle: tc.shareIdle, + }) + if err != nil { + t.Fatalf("Failed to get MCP tool response: %v", err) + } + + // Compare results + compareMCPWithHTTP(t, mcpResp, httpResp) + }) + } +} + diff --git a/test/integration/mcp/asset_mcp_vs_http_test.go b/test/integration/mcp/asset_mcp_vs_http_test.go new file mode 100644 index 0000000..5058a37 --- /dev/null +++ b/test/integration/mcp/asset_mcp_vs_http_test.go @@ -0,0 +1,282 @@ +package mcp + +// Integration tests to compare MCP get_asset_costs tool results with HTTP API /assets endpoint results +// This ensures that the MCP tool returns the same data as the HTTP API for asset queries +// +// Note: All tests use historical time windows (yesterday and earlier) to ensure consistent, +// reproducible results that don't change as new data arrives in the present. + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + "github.com/opencost/opencost-integration-tests/pkg/api" +) + +// MCPAssetRequest represents the request structure for get_asset_costs MCP tool +type MCPAssetRequest struct { + Window string `json:"window"` +} + +// MCPAssetData matches the MCP tool response format for assets +type MCPAssetData struct { + Data struct { + Assets map[string]struct { + Name string `json:"name"` + Assets []struct { + Type string `json:"type"` + Properties map[string]string `json:"properties"` + Labels map[string]string `json:"labels,omitempty"` + CPUCost float64 `json:"cpuCost"` + RAMCost float64 `json:"ramCost"` + GPUCost float64 `json:"gpuCost"` + TotalCost float64 `json:"totalCost"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + } `json:"assets"` + } `json:"assets"` + } `json:"data"` + QueryInfo struct { + QueryID string `json:"queryId"` + Timestamp string `json:"timestamp"` + ProcessingTime float64 `json:"processingTime"` + } `json:"queryInfo"` +} + +// callMCPAssetTool calls the MCP get_asset_costs tool +func callMCPAssetTool(req MCPAssetRequest) (*MCPAssetData, error) { + sessionID, err := initializeMCPSession() + if err != nil { + return nil, fmt.Errorf("failed to initialize MCP session: %w", err) + } + + mcpReq := struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + ID int `json:"id"` + Params struct { + Name string `json:"name"` + Arguments MCPAssetRequest `json:"arguments"` + } `json:"params"` + }{ + JSONRPC: "2.0", + Method: "tools/call", + ID: 1, + } + mcpReq.Params.Name = "get_asset_costs" + mcpReq.Params.Arguments = req + + jsonData, err := json.Marshal(mcpReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal MCP request: %w", err) + } + + url := getMCPURL() + "/mcp" + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + httpReq.Header.Set("Mcp-Session-Id", sessionID) + + client := &http.Client{} + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to call MCP tool: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("MCP tool returned status %d: %s", resp.StatusCode, string(body)) + } + + var mcpResp MCPResponse + if err := json.NewDecoder(resp.Body).Decode(&mcpResp); err != nil { + return nil, fmt.Errorf("failed to decode MCP response: %w", err) + } + + if mcpResp.Error != nil { + return nil, fmt.Errorf("MCP error (code %d): %s", mcpResp.Error.Code, mcpResp.Error.Message) + } + + if len(mcpResp.Result.Content) == 0 { + return nil, fmt.Errorf("empty content in MCP response") + } + + var textStr string + if err := json.Unmarshal(mcpResp.Result.Content[0].Text, &textStr); err != nil { + var mcpData MCPAssetData + if err2 := json.Unmarshal(mcpResp.Result.Content[0].Text, &mcpData); err2 != nil { + return nil, fmt.Errorf("failed to decode MCP asset data: %w, %w", err, err2) + } + return &mcpData, nil + } + + var mcpData MCPAssetData + if err := json.Unmarshal([]byte(textStr), &mcpData); err != nil { + return nil, fmt.Errorf("failed to decode MCP asset data from string: %w", err) + } + + return &mcpData, nil +} + +// TestMCPAssetVsHTTP compares MCP tool results with HTTP API results +func TestMCPAssetVsHTTP(t *testing.T) { + apiObj := api.NewAPI() + + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + threeDaysAgo := now.AddDate(0, 0, -3) + sevenDaysAgo := now.AddDate(0, 0, -7) + + testCases := []struct { + name string + window string + }{ + { + name: "Yesterday assets", + window: "yesterday", + }, + { + name: "Last 3 days (historical) assets", + window: fmt.Sprintf("%s,%s", threeDaysAgo.Format("2006-01-02T15:04:05Z"), yesterday.Format("2006-01-02T15:04:05Z")), + }, + { + name: "Last 7 days (historical) assets", + window: fmt.Sprintf("%s,%s", sevenDaysAgo.Format("2006-01-02T15:04:05Z"), yesterday.Format("2006-01-02T15:04:05Z")), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + httpResp, err := apiObj.GetAssets(api.AssetsRequest{ + Window: tc.window, + }) + if err != nil { + t.Fatalf("Failed to get HTTP API response: %v", err) + } + + if httpResp.Code != 200 { + t.Fatalf("HTTP API returned non-200 code: %d", httpResp.Code) + } + + mcpResp, err := callMCPAssetTool(MCPAssetRequest{ + Window: tc.window, + }) + if err != nil { + t.Fatalf("Failed to get MCP tool response: %v", err) + } + + compareMCPAssetWithHTTP(t, mcpResp, httpResp) + }) + } +} + +// extractAssetName extracts the simplified asset name from a fully-qualified HTTP API asset path +// For example: "custom/__undefined__/__undefined__/Compute/tilt-cluster/Node/Kubernetes/ocid1.../10.0.151.228" -> "10.0.151.228" +// Special cases: +// - Management assets ending with "__undefined__" map to the asset type (e.g., "ClusterManagement") +// - LoadBalancer assets have the format: .../LoadBalancer/Kubernetes/IP/namespace/service-name +func extractAssetName(fullPath string) string { + parts := strings.Split(fullPath, "/") + if len(parts) == 0 { + return fullPath + } + + lastPart := parts[len(parts)-1] + + // Special case: Management assets that end with __undefined__ + // These should use the asset type name (e.g., ClusterManagement) + if lastPart == "__undefined__" && len(parts) >= 6 { + for i := len(parts) - 2; i >= 0; i-- { + if parts[i] != "__undefined__" && parts[i] != "Kubernetes" { + return parts[i] + } + } + } + + // Special case: LoadBalancer assets + // Path format: .../Network/.../LoadBalancer/Kubernetes/IP/namespace/service-name + // We want to return "namespace/service-name" + for i, part := range parts { + if part == "LoadBalancer" && i+4 < len(parts) { + // Return namespace/service-name format (skip IP, use namespace/service-name) + return parts[i+3] + "/" + parts[i+4] + } + } + + return lastPart +} + +// compareMCPAssetWithHTTP compares MCP asset data with HTTP API asset data +func compareMCPAssetWithHTTP(t *testing.T, mcpData *MCPAssetData, httpData *api.AssetsResponse) { + mcpAssets := mcpData.Data.Assets["assets"] + if len(mcpAssets.Assets) == 0 { + t.Log("Warning: MCP response has no assets") + } + + if len(httpData.Data) == 0 { + t.Log("Warning: HTTP response has no data") + } + + mcpMap := make(map[string]float64) + for _, asset := range mcpAssets.Assets { + name := asset.Properties["name"] + if name == "" { + name = asset.Type + } + mcpMap[name] = asset.TotalCost + } + + // Build HTTP map with extracted simplified names + httpMap := make(map[string]float64) + httpFullPathMap := make(map[string]string) // Maps simplified name to full path for logging + for fullPath, item := range httpData.Data { + simplifiedName := extractAssetName(fullPath) + httpMap[simplifiedName] = item.TotalCost + httpFullPathMap[simplifiedName] = fullPath + } + + for name := range mcpMap { + if _, exists := httpMap[name]; !exists { + t.Errorf("Asset '%s' exists in MCP but not in HTTP API", name) + } + } + + for name := range httpMap { + if _, exists := mcpMap[name]; !exists { + t.Errorf("Asset '%s' exists in HTTP API but not in MCP (full path: %s)", name, httpFullPathMap[name]) + } + } + + const tolerance = 0.01 + matchCount := 0 + for name, mcpCost := range mcpMap { + if httpCost, exists := httpMap[name]; exists { + diff := abs(mcpCost - httpCost) + percentDiff := 0.0 + if httpCost != 0 { + percentDiff = (diff / httpCost) * 100 + } + + if diff > tolerance && percentDiff > 0.1 { + t.Errorf("Cost mismatch for '%s': MCP=%.4f, HTTP=%.4f (diff=%.4f, %.2f%%)", + name, mcpCost, httpCost, diff, percentDiff) + } else { + matchCount++ + t.Logf("Cost match for '%s': MCP=%.4f, HTTP=%.4f", name, mcpCost, httpCost) + } + } + } + + t.Logf("Comparison complete: %d MCP assets, %d HTTP assets, %d matches", + len(mcpMap), len(httpMap), matchCount) +} + diff --git a/test/integration/mcp/helpers.go b/test/integration/mcp/helpers.go new file mode 100644 index 0000000..2e2b34a --- /dev/null +++ b/test/integration/mcp/helpers.go @@ -0,0 +1,122 @@ +package mcp + +// Shared helper functions and types for MCP integration tests + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "github.com/opencost/opencost-integration-tests/pkg/env" +) + +// MCPResponse represents the MCP server response +type MCPResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result struct { + Content []struct { + Type string `json:"type"` + Text json.RawMessage `json:"text,omitempty"` + } `json:"content"` + } `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// getMCPURL returns the MCP HTTP server URL +func getMCPURL() string { + mcpURL := env.GetMCPURL() + if mcpURL == "" { + // Default to port 8081 if not set + return "http://localhost:8081" + } + return mcpURL +} + +// initializeMCPSession initializes an MCP session and returns the session ID +func initializeMCPSession() (string, error) { + url := getMCPURL() + "/mcp" + + initReq := map[string]interface{}{ + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{}, + "clientInfo": map[string]string{ + "name": "opencost-integration-test", + "version": "1.0.0", + }, + }, + } + + jsonData, err := json.Marshal(initReq) + if err != nil { + return "", fmt.Errorf("failed to marshal init request: %w", err) + } + + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create HTTP request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + + client := &http.Client{} + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("failed to initialize MCP session: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("MCP init returned status %d: %s", resp.StatusCode, string(body)) + } + + // Extract session ID from response header + sessionID := resp.Header.Get("Mcp-Session-Id") + if sessionID == "" { + return "", fmt.Errorf("no session ID returned from MCP server") + } + + // Send initialized notification with session ID + notifyReq := map[string]interface{}{ + "jsonrpc": "2.0", + "method": "notifications/initialized", + } + + jsonData, err = json.Marshal(notifyReq) + if err != nil { + return "", fmt.Errorf("failed to marshal notify request: %w", err) + } + + httpReq, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create notify request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + httpReq.Header.Set("Mcp-Session-Id", sessionID) + + resp, err = client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("failed to send initialized notification: %w", err) + } + defer resp.Body.Close() + + return sessionID, nil +} + +// abs returns absolute value of a float64 +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +} diff --git a/test/integration/mcp/test.bats b/test/integration/mcp/test.bats new file mode 100644 index 0000000..6fc2714 --- /dev/null +++ b/test/integration/mcp/test.bats @@ -0,0 +1,17 @@ +setup() { + DIR="$( cd "$( dirname "$BATS_TEST_FILENAME" )" >/dev/null 2>&1 && pwd )" + cd $DIR +} + +teardown() { + : # nothing to tear down +} + +@test "mcp: allocation costs vs HTTP API" { + go test allocation_mcp_vs_http_test.go helpers.go +} + +@test "mcp: asset costs vs HTTP API" { + go test asset_mcp_vs_http_test.go helpers.go +} +