From dee33b0c4900574cd7f97d352b1b9e519ae47ff9 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 2 Oct 2026 18:24:14 +0200 Subject: [PATCH 1/2] fix(search): reject incomplete AWS recommendation menus Pin the published AWS completeness diagnostics and verify valid, empty, mixed, and all-invalid results through the registered MCP tool. Refs LeanerCloud/cloud-commitments-go#54 --- go.mod | 6 +- go.sum | 8 +- ...earch_recommendations_completeness_test.go | 156 ++++++++++++++++++ 3 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 tools/search_recommendations_completeness_test.go diff --git a/go.mod b/go.mod index 5bed290..d8bac13 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/LeanerCloud/cloud-commitments-mcp go 1.26.6 require ( - github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.29.12 // indirect github.com/aws/aws-sdk-go-v2/service/costexplorer v1.61.0 // indirect github.com/aws/aws-sdk-go-v2/service/ec2 v1.251.2 @@ -77,8 +77,8 @@ require ( ) require ( - github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260928214714-ce9513612901 - github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20260928214714-ce9513612901 + github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260929105827-b3b4cb5e3d80 + github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261002152209-006ef5c8d0a2 github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928214714-ce9513612901 github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20260928214714-ce9513612901 github.com/google/jsonschema-go v0.4.3 diff --git a/go.sum b/go.sum index 0eff436..ecc0884 100644 --- a/go.sum +++ b/go.sum @@ -76,10 +76,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260928214714-ce9513612901 h1:JWQwkwshjBoEuoIuFK1KHh70yZDYQTrm5GRloD6ojjU= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260928214714-ce9513612901/go.mod h1:ApWBliDXe099f3oDXBz41K/I9v4bHvn1dG/BGoRmHlw= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20260928214714-ce9513612901 h1:18MxQd7+ZQ+Z2uNbZfpIJQ00JWdSdTVSmVWBzg4R0DA= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20260928214714-ce9513612901/go.mod h1:tIUCLVX6utdBPiP6dAjY7GUfwIQngeSMK5A9aRqe6gA= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260929105827-b3b4cb5e3d80 h1:wVKlMokfaME/Lw525Qz3F155R3nY4VmuaIyh+6d4sCU= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260929105827-b3b4cb5e3d80/go.mod h1:ApWBliDXe099f3oDXBz41K/I9v4bHvn1dG/BGoRmHlw= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261002152209-006ef5c8d0a2 h1:rYxXq0G0O2lkf8AB1ni9m4pVhIdb6Z0y52sTxS1RKIA= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261002152209-006ef5c8d0a2/go.mod h1:d4nsy61/Ptib0SxqMg0ble3yksVm3+QbtUtmGja82PA= github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928214714-ce9513612901 h1:iSdHYdmGUjcjtSmgGZxptBzDuLrJ9KR2mh1/x7FNvSY= github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928214714-ce9513612901/go.mod h1:zgCL/ozOkcZUDbEC7a2UwW+6MPcLoBlEU2WUZ0TORUs= github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20260928214714-ce9513612901 h1:i6OwXLUheudN3GfwnYXdKuEq8vPdE9qkNJ+r71LRLKA= diff --git a/tools/search_recommendations_completeness_test.go b/tools/search_recommendations_completeness_test.go new file mode 100644 index 0000000..d84afde --- /dev/null +++ b/tools/search_recommendations_completeness_test.go @@ -0,0 +1,156 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + awsprovider "github.com/LeanerCloud/cloud-commitments-go/providers/aws" +) + +func TestSearchRecommendationsAWSCompletenessProtocol(t *testing.T) { + t.Parallel() + for _, selected := range []bool{false, true} { + for _, kind := range []string{"valid", "empty", "mixed", "all-invalid"} { + t.Run(fmt.Sprintf("selected=%t/%s", selected, kind), func(t *testing.T) { + t.Parallel() + fixture := &recommendationCompletenessHTTP{kind: kind} + adapter := awsprovider.NewRecommendationsClient(aws.Config{ + Region: "us-east-1", HTTPClient: fixture, + Credentials: aws.CredentialsProviderFunc(func(context.Context) (aws.Credentials, error) { + return aws.Credentials{AccessKeyID: "synthetic", SecretAccessKey: "synthetic"}, nil + }), + }) + tool := newTestSearchTool(&fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceRDS}, recClient: adapter}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + server := mcp.NewServer(&mcp.Implementation{Name: "completeness-server"}, nil) + require.NoError(t, tool.Register(server)) + clientTransport, serverTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, serverTransport, nil) + require.NoError(t, err) + defer serverSession.Close() + client := mcp.NewClient(&mcp.Implementation{Name: "completeness-client"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + defer session.Close() + args := map[string]any{"provider": "aws", "service": "rds"} + wantRequests := []recommendationCompletenessRequest{ + {"ONE_YEAR", "ALL_UPFRONT"}, {"ONE_YEAR", "PARTIAL_UPFRONT"}, {"ONE_YEAR", "NO_UPFRONT"}, + {"THREE_YEARS", "ALL_UPFRONT"}, {"THREE_YEARS", "PARTIAL_UPFRONT"}, {"THREE_YEARS", "NO_UPFRONT"}, + } + if selected { + args["term_years"], args["payment_option"] = 3, "no-upfront" + wantRequests = wantRequests[5:] + } + result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: searchRecommendationsName, Arguments: args}) + require.NoError(t, err) + require.Empty(t, fixture.unexpected) + if kind == "all-invalid" { + wantRequests = wantRequests[:1] + } + assert.Equal(t, wantRequests, fixture.requests) + if kind == "mixed" || kind == "all-invalid" { + require.True(t, result.IsError, "incomplete response produced a successful short menu: %+v", result.StructuredContent) + require.Nil(t, result.StructuredContent) + var diagnostic strings.Builder + for _, content := range result.Content { + part, ok := content.(*mcp.TextContent) + require.True(t, ok) + diagnostic.WriteString(part.Text) + } + index, term, payment := 1, "3yr", "no-upfront" + if kind == "all-invalid" { + index = 0 + if !selected { + term, payment = "1yr", "all-upfront" + } + } + assert.Regexp(t, `\b1 failed details\b`, diagnostic.String()) + assert.Regexp(t, `\b0 failed scopes\b`, diagnostic.String()) + for _, want := range []string{fmt.Sprintf("block 0 detail %d", index), term, payment, "not-a-number"} { + assert.Contains(t, diagnostic.String(), want) + } + if !selected { + assert.Contains(t, diagnostic.String(), "term="+term+", payment_option="+payment) + } + return + } + require.False(t, result.IsError) + encoded, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + var menu struct { + Count int `json:"count"` + Recommendations []json.RawMessage `json:"recommendations"` + } + require.NoError(t, json.Unmarshal(encoded, &menu)) + wantCount := len(wantRequests) + if kind == "empty" { + wantCount = 0 + } + assert.Equal(t, wantCount, menu.Count) + assert.Len(t, menu.Recommendations, wantCount) + assert.NotNil(t, menu.Recommendations) + }) + } + } +} + +type recommendationCompletenessRequest struct { + Term string `json:"TermInYears"` + Payment string `json:"PaymentOption"` +} + +type recommendationCompletenessHTTP struct { + kind string + requests []recommendationCompletenessRequest + unexpected []string +} + +func (f *recommendationCompletenessHTTP) Do(req *http.Request) (*http.Response, error) { + operation := req.Header.Get("X-Amz-Target") + if operation != "AWSInsightsIndexService.GetReservationPurchaseRecommendation" { + f.unexpected = append(f.unexpected, operation) + return nil, fmt.Errorf("unexpected SDK operation: %s", operation) + } + var params recommendationCompletenessRequest + if err := json.NewDecoder(req.Body).Decode(¶ms); err != nil { + return nil, err + } + f.requests = append(f.requests, params) + valid := map[string]any{ + "RecommendedNumberOfInstancesToPurchase": "2", + "EstimatedMonthlySavingsAmount": "10", "EstimatedMonthlyOnDemandCost": "30", + "InstanceDetails": map[string]any{"RDSInstanceDetails": map[string]any{ + "InstanceType": "db.t3.medium", "Region": "us-east-1", "DeploymentOption": "Single-AZ", + }}, + } + invalid := map[string]any{"RecommendedNumberOfInstancesToPurchase": "not-a-number"} + details := []map[string]any{valid} + switch f.kind { + case "empty": + details = nil + case "all-invalid": + details = []map[string]any{invalid} + case "mixed": + if params.Term == "THREE_YEARS" && params.Payment == "NO_UPFRONT" { + details = append(details, invalid) + } + } + body, err := json.Marshal(map[string]any{"Recommendations": []any{map[string]any{"RecommendationDetails": details}}}) + if err != nil { + return nil, err + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/x-amz-json-1.1"}}, Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil +} From d04d15bb6fec29e99ee7a3a7d21a5ecdf3b44a4a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 3 Oct 2026 20:32:16 +0200 Subject: [PATCH 2/2] fix(deps): select patched opentelemetry sdk Resolve GO-2026-6505 with the SDK 1.45.0 requirement closure while preserving the published shared-library and provider pins. --- go.mod | 12 ++++++------ go.sum | 28 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index d8bac13..002fd8e 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,7 @@ require ( github.com/aws/smithy-go v1.24.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -57,9 +57,9 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect golang.org/x/crypto v0.56.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect @@ -114,6 +114,6 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect ) diff --git a/go.sum b/go.sum index ecc0884..5ce6c86 100644 --- a/go.sum +++ b/go.sum @@ -147,8 +147,8 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -207,20 +207,20 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=