From 26b567040aeb2a96456764488b8715683243b743 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 9 Oct 2026 16:15:15 +0200 Subject: [PATCH 1/3] fix(mcp): require operator spend caps before any real purchase Real purchases are refused unless CUDLY_MCP_MAX_COUNT (RIs, GCP vCPUs), CUDLY_MCP_MAX_HOURLY_COMMITMENT (Savings Plans) or CUDLY_MCP_MAX_MEMORY_GB (GCP CUDs) is set to a valid value and the request is within it. Caps are routed by CommitmentType, checked after the opt-in and before credentials. They are per call and are not USD caps; a USD cap and cumulative budget are tracked in #51. Refs #47 Co-Authored-By: Claude Sonnet 5.5 --- cmd/cudly-mcp/gcp_purchase_protocol_test.go | 3 + cmd/cudly-mcp/main_test.go | 3 + tools/aws_ec2_ri.go | 2 +- tools/aws_elasticache_ri.go | 2 +- tools/aws_rds_ri.go | 2 +- tools/aws_simple_ri.go | 2 +- tools/azure_compute_ri.go | 2 +- tools/gcp_computeengine_cud.go | 4 +- tools/purchase.go | 6 +- tools/purchase_test.go | 10 + tools/spend_caps.go | 153 +++++++++++++ tools/spend_caps_test.go | 227 ++++++++++++++++++++ 12 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 tools/spend_caps.go create mode 100644 tools/spend_caps_test.go diff --git a/cmd/cudly-mcp/gcp_purchase_protocol_test.go b/cmd/cudly-mcp/gcp_purchase_protocol_test.go index 5d8a01e..1a300a2 100644 --- a/cmd/cudly-mcp/gcp_purchase_protocol_test.go +++ b/cmd/cudly-mcp/gcp_purchase_protocol_test.go @@ -127,6 +127,9 @@ func (p *fakeGCPProvider) GetRecommendationsClient(_ context.Context) (provider. // lives at the library boundary (tools/gcp_computeengine_cud_safeguards_test.go). func TestGCPComputeEngineCUDPurchaseStructuredCost(t *testing.T) { t.Setenv(tools.EnvEnableRealPurchases, "1") + t.Setenv(tools.EnvMaxCount, "1000") + t.Setenv(tools.EnvMaxHourlyCommitment, "1000") + t.Setenv(tools.EnvMaxMemoryGB, "1000") service := &fakeComputeEngineCommitmentsService{} provider.GetRegistry().Unregister("gcp") diff --git a/cmd/cudly-mcp/main_test.go b/cmd/cudly-mcp/main_test.go index 539119f..d8b3856 100644 --- a/cmd/cudly-mcp/main_test.go +++ b/cmd/cudly-mcp/main_test.go @@ -300,6 +300,9 @@ func TestRealPurchasePastProviderRegistration(t *testing.T) { // must clear the operator-side EnvEnableRealPurchases gate too, or every // assertion below would instead observe the gate's own refusal. t.Setenv(tools.EnvEnableRealPurchases, "1") + t.Setenv(tools.EnvMaxCount, "1000") + t.Setenv(tools.EnvMaxHourlyCommitment, "1000") + t.Setenv(tools.EnvMaxMemoryGB, "1000") server, err := cudlymcp.NewServer("test-regression") require.NoError(t, err) diff --git a/tools/aws_ec2_ri.go b/tools/aws_ec2_ri.go index bc9d120..ef188f5 100644 --- a/tools/aws_ec2_ri.go +++ b/tools/aws_ec2_ri.go @@ -40,7 +40,7 @@ type ec2RIPurchaseArgs struct { Scope string `json:"scope,omitempty" jsonschema:"region or availability-zone; defaults to region"` AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional; set to a fresh value to authorize a purchase that is otherwise identical to a previous one (e.g. buy 3 more RIs with the same parameters); leave empty (the default) so retries with identical parameters dedupe and never double-buy"` - Count int `json:"count" jsonschema:"number of instances to reserve, must be > 0"` + Count int `json:"count" jsonschema:"number of instances to reserve, must be > 0; real purchases are capped by the operator (CUDLY_MCP_MAX_COUNT)"` TermYears int `json:"term_years" jsonschema:"commitment length in years"` } diff --git a/tools/aws_elasticache_ri.go b/tools/aws_elasticache_ri.go index 4ce4e0c..27912f6 100644 --- a/tools/aws_elasticache_ri.go +++ b/tools/aws_elasticache_ri.go @@ -33,7 +33,7 @@ type elasticacheRIPurchaseArgs struct { Engine string `json:"engine" jsonschema:"cache engine"` AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional; set to a fresh value to authorize a purchase that is otherwise identical to a previous one (e.g. buy 3 more RIs with the same parameters); leave empty (the default) so retries with identical parameters dedupe and never double-buy"` - Count int `json:"count" jsonschema:"number of cache nodes to reserve, must be > 0"` + Count int `json:"count" jsonschema:"number of cache nodes to reserve, must be > 0; real purchases are capped by the operator (CUDLY_MCP_MAX_COUNT)"` TermYears int `json:"term_years" jsonschema:"commitment length in years"` } diff --git a/tools/aws_rds_ri.go b/tools/aws_rds_ri.go index d61948a..630f8d8 100644 --- a/tools/aws_rds_ri.go +++ b/tools/aws_rds_ri.go @@ -36,7 +36,7 @@ type rdsRIPurchaseArgs struct { AZConfig string `json:"az_config" jsonschema:"single-az or multi-az; must match the recommendation exactly (different price, no cross-coverage)"` AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional; set to a fresh value to authorize a purchase that is otherwise identical to a previous one (e.g. buy 3 more RIs with the same parameters); leave empty (the default) so retries with identical parameters dedupe and never double-buy"` - Count int `json:"count" jsonschema:"number of instances to reserve, must be > 0"` + Count int `json:"count" jsonschema:"number of instances to reserve, must be > 0; real purchases are capped by the operator (CUDLY_MCP_MAX_COUNT)"` TermYears int `json:"term_years" jsonschema:"commitment length in years"` } diff --git a/tools/aws_simple_ri.go b/tools/aws_simple_ri.go index f82fcae..2409b1a 100644 --- a/tools/aws_simple_ri.go +++ b/tools/aws_simple_ri.go @@ -36,7 +36,7 @@ type simpleAWSRIPurchaseArgs struct { PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional; set to a fresh value to authorize a purchase that is otherwise identical to a previous one (e.g. buy 3 more RIs with the same parameters); leave empty (the default) so retries with identical parameters dedupe and never double-buy"` - Count int `json:"count" jsonschema:"number of nodes/instances to reserve, must be > 0"` + Count int `json:"count" jsonschema:"number of nodes/instances to reserve, must be > 0; real purchases are capped by the operator (CUDLY_MCP_MAX_COUNT)"` TermYears int `json:"term_years" jsonschema:"commitment length in years"` } diff --git a/tools/azure_compute_ri.go b/tools/azure_compute_ri.go index ec0e904..ccae651 100644 --- a/tools/azure_compute_ri.go +++ b/tools/azure_compute_ri.go @@ -46,7 +46,7 @@ type azureComputeRIPurchaseArgs struct { PaymentOption string `json:"payment_option,omitempty" jsonschema:"payment schedule; Azure honors all-upfront and no-upfront (monthly, same total price); no partial-upfront; defaults to no-upfront"` AzureSubscriptionID string `json:"azure_subscription_id,omitempty" jsonschema:"Azure subscription ID override; default uses AZURE_SUBSCRIPTION_ID"` IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional; set to a fresh value to authorize a purchase that is otherwise identical to a previous one (e.g. buy 3 more RIs with the same parameters); leave empty (the default) so retries with identical parameters dedupe and never double-buy"` - Count int `json:"count" jsonschema:"number of VM instances to reserve, must be > 0"` + Count int `json:"count" jsonschema:"number of VM instances to reserve, must be > 0; real purchases are capped by the operator (CUDLY_MCP_MAX_COUNT)"` TermYears int `json:"term_years" jsonschema:"commitment length in years"` } diff --git a/tools/gcp_computeengine_cud.go b/tools/gcp_computeengine_cud.go index 8c26b15..c7f199b 100644 --- a/tools/gcp_computeengine_cud.go +++ b/tools/gcp_computeengine_cud.go @@ -42,8 +42,8 @@ type gcpComputeEngineCUDPurchaseArgs struct { MachineType string `json:"machine_type" jsonschema:"GCP machine type family for the commitment, e.g. n2-standard-4"` GCPProjectID string `json:"gcp_project_id,omitempty" jsonschema:"GCP project ID to buy for. Optional for a dry_run preview (the ambient project is used); REQUIRED for a real purchase (dry_run=false, confirm=true), which is refused without it because GCP has no ambient project variable that reliably names the target project"` IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional; set to a fresh value to authorize a purchase that is otherwise identical to a previous one (e.g. buy 3 more RIs with the same parameters); leave empty (the default) so retries with identical parameters dedupe and never double-buy"` - VCPUCount int `json:"vcpu_count" jsonschema:"number of vCPUs to commit, must be > 0"` - MemoryGB float64 `json:"memory_gb" jsonschema:"amount of memory (GB) to commit, must be > 0"` + VCPUCount int `json:"vcpu_count" jsonschema:"number of vCPUs to commit, must be > 0; real purchases are capped by the operator (CUDLY_MCP_MAX_COUNT)"` + MemoryGB float64 `json:"memory_gb" jsonschema:"amount of memory (GB) to commit, must be > 0; real purchases are capped by the operator (CUDLY_MCP_MAX_MEMORY_GB)"` TermYears int `json:"term_years" jsonschema:"commitment length in years"` } diff --git a/tools/purchase.go b/tools/purchase.go index fa59a86..567bee5 100644 --- a/tools/purchase.go +++ b/tools/purchase.go @@ -299,7 +299,7 @@ func ResolveDryRunConfirm(dryRun, confirm *bool) (effectiveDryRun, effectiveConf // a field a caller may key billing or renewal reminders off. Nil (and // omitted) when the provider reported no timestamp. type PurchaseResponse struct { - Cost *float64 `json:"cost,omitempty"` + Cost *float64 `json:"cost,omitempty" jsonschema:"USD. Preview (Savings Plans only): total commitment over the whole term, not per hour. Executed purchase: provider-reported upfront cost. Omitted when unknown"` OnDemandCost *float64 `json:"on_demand_cost,omitempty"` EstimatedSavings *float64 `json:"estimated_savings,omitempty"` SavingsPercentage *float64 `json:"savings_percentage,omitempty"` @@ -579,6 +579,10 @@ func authorizeRealPurchase(req PurchaseRequest, rec common.Recommendation) error return fmt.Errorf("real purchases are disabled: set %s=1 to allow the MCP server to execute real purchases", EnvEnableRealPurchases) } + // Operator spend ceiling, per call. See enforceSpendCaps. + if err := enforceSpendCaps(rec); err != nil { + return err + } // Target account must be determinable. See requireCredentialScope for why // an undeterminable one is a double-purchase hazard, not a cosmetic gap. if err := requireCredentialScope(rec.Provider, req.CredentialScope); err != nil { diff --git a/tools/purchase_test.go b/tools/purchase_test.go index 5638216..7030e85 100644 --- a/tools/purchase_test.go +++ b/tools/purchase_test.go @@ -41,6 +41,16 @@ func TestMain(m *testing.M) { os.Exit(1) } + // Generous default caps so suites that predate the spend ceiling keep + // exercising what happens once a purchase is authorized. The cap tests + // override these (non-parallel) per case. + for env, v := range map[string]string{EnvMaxCount: "1000000", EnvMaxHourlyCommitment: "1000000", EnvMaxMemoryGB: "1000000"} { + if err := os.Setenv(env, v); err != nil { + log.Printf("set %s for MCP tests: %v", env, err) + os.Exit(1) + } + } + auditDir, err := os.MkdirTemp("", "cudly-mcp-audit-testmain") if err != nil { log.Printf("create MCP tool audit test directory: %v", err) diff --git a/tools/spend_caps.go b/tools/spend_caps.go new file mode 100644 index 0000000..2c7c55d --- /dev/null +++ b/tools/spend_caps.go @@ -0,0 +1,153 @@ +package tools + +import ( + "fmt" + "math" + "os" + "strconv" + "strings" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" +) + +// Operator-set spend caps. They are environment variables, not tool +// arguments: a cap the model could raise would not bound a hallucinating or +// prompt-injected model. Each applies PER CALL, not cumulatively, and +// none is a USD cap: they bound the size of one purchase request. +const ( + // EnvMaxCount caps rec.Count (instances or nodes; vCPUs for GCP CUDs) + // for every non-Savings-Plan real purchase. + EnvMaxCount = "CUDLY_MCP_MAX_COUNT" + // EnvMaxHourlyCommitment caps the USD/hour commitment of a Savings Plan. + EnvMaxHourlyCommitment = "CUDLY_MCP_MAX_HOURLY_COMMITMENT" + // EnvMaxMemoryGB caps the memory committed by a GCP CUD. MaxCount bounds + // only vcpu_count, so memory_gb needs its own ceiling. + EnvMaxMemoryGB = "CUDLY_MCP_MAX_MEMORY_GB" +) + +// capSetting names one cap for error messages: the environment variable and, +// for the MCPB bundle, the Claude Desktop setting that feeds it. +type capSetting struct { + env string + desktop string +} + +var ( + capCount = capSetting{EnvMaxCount, "Maximum count per purchase call"} + capHourly = capSetting{EnvMaxHourlyCommitment, "Maximum hourly commitment (USD/hour)"} + capMemory = capSetting{EnvMaxMemoryGB, "Maximum committed memory (GB)"} +) + +func (c capSetting) unset() error { + return fmt.Errorf("refusing real purchase: spend cap %s is not set; set it (Claude Desktop: %q) to the largest value one purchase call may use", + c.env, c.desktop) +} + +func (c capSetting) invalid(kind string) error { + return fmt.Errorf("refusing real purchase: spend cap %s must be a positive %s (Claude Desktop: %q)", + c.env, kind, c.desktop) +} + +// readCapRaw returns the trimmed value of the cap variable, or the unset error +// for both an unset and an empty/whitespace-only variable. +func (c capSetting) readRaw() (string, error) { + v := strings.TrimSpace(os.Getenv(c.env)) + if v == "" { + return "", c.unset() + } + return v, nil +} + +func (c capSetting) readInt() (int, error) { + v, err := c.readRaw() + if err != nil { + return 0, err + } + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + return 0, c.invalid("integer") + } + return n, nil +} + +func (c capSetting) readFloat() (float64, error) { + v, err := c.readRaw() + if err != nil { + return 0, err + } + f, err := strconv.ParseFloat(v, 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) || f <= 0 { + return 0, c.invalid("number") + } + return f, nil +} + +// enforceSpendCaps refuses a real purchase whose size exceeds the operator +// caps, or whose cap is not configured. Caps are routed by CommitmentType, not +// by Count==0, so a Savings Plan can never skip its hourly cap by carrying a +// zero count. Fail closed: a missing, empty or invalid cap is a refusal, never +// "unlimited". +func enforceSpendCaps(rec common.Recommendation) error { + if rec.CommitmentType == common.CommitmentSavingsPlan { + return enforceHourlyCap(rec) + } + if err := enforceCountCap(rec); err != nil { + return err + } + if rec.CommitmentType == common.CommitmentCUD { + return enforceMemoryCap(rec) + } + return nil +} + +func enforceHourlyCap(rec common.Recommendation) error { + details, ok := rec.Details.(*common.SavingsPlanDetails) + if !ok || details == nil { + return fmt.Errorf("refusing real purchase: Savings Plan recommendation carries no hourly commitment to check against %s", EnvMaxHourlyCommitment) + } + limit, err := capHourly.readFloat() + if err != nil { + return err + } + h := details.HourlyCommitment + if math.IsNaN(h) || math.IsInf(h, 0) || h <= 0 { + return fmt.Errorf("refusing real purchase: hourly commitment must be a positive finite number") + } + if h > limit { + return fmt.Errorf("refusing real purchase: hourly_commitment %v exceeds the operator cap %s=%v", h, EnvMaxHourlyCommitment, limit) + } + return nil +} + +func enforceCountCap(rec common.Recommendation) error { + limit, err := capCount.readInt() + if err != nil { + return err + } + if rec.Count < 1 { + return fmt.Errorf("refusing real purchase: count must be at least 1, got %d", rec.Count) + } + if rec.Count > limit { + return fmt.Errorf("refusing real purchase: count %d exceeds the operator cap %s=%d", rec.Count, EnvMaxCount, limit) + } + return nil +} + +func enforceMemoryCap(rec common.Recommendation) error { + details, ok := rec.Details.(common.ComputeDetails) + if !ok { + return fmt.Errorf("refusing real purchase: GCP CUD recommendation carries no memory to check against %s", EnvMaxMemoryGB) + } + limit, err := capMemory.readFloat() + if err != nil { + return err + } + m := details.MemoryGB + if math.IsNaN(m) || math.IsInf(m, 0) || m <= 0 { + return fmt.Errorf("refusing real purchase: memory_gb must be a positive finite number") + } + if m > limit { + return fmt.Errorf("refusing real purchase: memory_gb %v exceeds the operator cap %s=%v", m, EnvMaxMemoryGB, limit) + } + return nil +} diff --git a/tools/spend_caps_test.go b/tools/spend_caps_test.go new file mode 100644 index 0000000..a0f4640 --- /dev/null +++ b/tools/spend_caps_test.go @@ -0,0 +1,227 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/provider" +) + +// These tests use t.Setenv and so are deliberately not parallel. + +func setCaps(t *testing.T, count, hourly, memory string) { + t.Helper() + t.Setenv(EnvMaxCount, count) + t.Setenv(EnvMaxHourlyCommitment, hourly) + t.Setenv(EnvMaxMemoryGB, memory) +} + +// runReal drives ExecutePurchase for a real, confirmed purchase and reports +// whether the provider client was ever resolved or called. +func runReal(t *testing.T, rec common.Recommendation) (resp *PurchaseResponse, resolved bool, calls int, err error) { + t.Helper() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "id"}} + resp, err = ExecutePurchase(context.Background(), PurchaseRequest{ + Region: rec.Region, Recommendation: rec, Confirm: true, CredentialScope: "scope", + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { + resolved = true + return fake, nil + }, + }) + return resp, resolved, fake.purchaseCalls, err +} + +func spRec(t *testing.T, hourly float64) common.Recommendation { + t.Helper() + args := validSavingsPlansArgs() + args.HourlyCommitment = hourly + args.PaymentOption = "all-upfront" + rec, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err) + return rec +} + +func TestSpendCapSavingsPlanHourly(t *testing.T) { + // The issue's failing shape: 50000/h, 3y, all-upfront. + setCaps(t, "1", "100", "1") + rec := spRec(t, 50000) + require.Equal(t, 0, rec.Count, "a Savings Plan carries Count 0; it must still hit the hourly cap") + + resp, resolved, calls, err := runReal(t, rec) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), EnvMaxHourlyCommitment) + assert.Contains(t, err.Error(), "50000") + assert.Contains(t, err.Error(), "100") + assert.False(t, resolved, "a refusal must not resolve credentials") + assert.Zero(t, calls) + + _, _, calls, err = runReal(t, spRec(t, 100)) + require.NoError(t, err, "exactly at the cap is allowed") + assert.Equal(t, 1, calls) + + _, _, _, err = runReal(t, spRec(t, 100.01)) + require.Error(t, err, "just over the cap is refused") +} + +func TestSpendCapSavingsPlanWithoutDetailsRefused(t *testing.T) { + setCaps(t, "1000", "1000", "1000") + rec := spRec(t, 1) + rec.Details = nil + _, resolved, calls, err := runReal(t, rec) + require.Error(t, err) + assert.False(t, resolved) + assert.Zero(t, calls) +} + +func TestSpendCapRoutesByCommitmentTypeNotCount(t *testing.T) { + // Mutation probe: a Savings Plan with Count 0 must not skip the hourly cap + // and a non-SP with Count 0 must be refused, not waved through. + setCaps(t, "1000", "1", "1000") + rec := spRec(t, 5) + rec.Count = 0 + _, _, _, err := runReal(t, rec) + require.ErrorContains(t, err, EnvMaxHourlyCommitment) + + rec.Count = 5 // a non-zero count must not reroute it to the count cap + _, _, _, err = runReal(t, rec) + require.ErrorContains(t, err, EnvMaxHourlyCommitment) + + ec2, _, _, _, err2 := ec2RecommendationFromArgs(validEC2Args()) + require.NoError(t, err2) + ec2.Count = 0 + _, _, calls, err := runReal(t, ec2) + require.Error(t, err) + assert.Zero(t, calls) +} + +func TestSpendCapRejectsUnsetAndInvalid(t *testing.T) { + ec2, _, _, _, err := ec2RecommendationFromArgs(validEC2Args()) + require.NoError(t, err) + gcp, _, _, _, err := gcpComputeEngineRecommendationFromArgs(validGCPCUDArgs()) + require.NoError(t, err) + gcp.Count = 1 + + bad := []string{"", " ", "abc", "0", "-1", "1e3", "1.5", "${user_config.max_count}"} + for _, v := range bad { + setCaps(t, v, "10", "10") + _, resolved, calls, runErr := runReal(t, ec2) + require.ErrorContains(t, runErr, EnvMaxCount, "count cap %q", v) + if v != "" && v != " " { + require.ErrorContains(t, runErr, "must be a positive integer", "count cap %q", v) + } + assert.NotContains(t, runErr.Error(), "abc", "the invalid value must not be echoed") + assert.False(t, resolved) + assert.Zero(t, calls) + } + badFloat := append([]string{"NaN", "Inf", "-Inf"}, bad...) + for _, v := range badFloat { + if v == "1e3" || v == "1.5" { + continue // valid positive numbers for float caps + } + setCaps(t, "10", v, "10") + _, _, _, runErr := runReal(t, spRec(t, 1)) + require.ErrorContains(t, runErr, EnvMaxHourlyCommitment, "hourly cap %q", v) + + setCaps(t, "10", "10", v) + _, _, _, runErr = runReal(t, gcp) + require.ErrorContains(t, runErr, EnvMaxMemoryGB, "memory cap %q", v) + } + // Whitespace around a valid value is trimmed. + setCaps(t, " 5 ", " 5 ", " 5 ") + _, _, calls, err := runReal(t, ec2) + require.NoError(t, err) + assert.Equal(t, 1, calls) +} + +func TestSpendCapMessagesNameDesktopSetting(t *testing.T) { + setCaps(t, "", "", "") + ec2, _, _, _, err := ec2RecommendationFromArgs(validEC2Args()) + require.NoError(t, err) + _, _, _, err = runReal(t, ec2) + require.ErrorContains(t, err, capCount.desktop) +} + +func TestSpendCapCountAcrossEveryCountBearingBuilder(t *testing.T) { + var recs = map[string]common.Recommendation{} + var err error + recs["ec2"], _, _, _, err = ec2RecommendationFromArgs(validEC2Args()) + require.NoError(t, err) + recs["rds"], _, _, _, err = rdsRecommendationFromArgs(validRDSArgs()) + require.NoError(t, err) + recs["elasticache"], _, _, _, err = elasticacheRecommendationFromArgs(validElastiCacheArgs()) + require.NoError(t, err) + recs["azure"], _, _, _, err = azureComputeRecommendationFromArgs(validAzureComputeArgs()) + require.NoError(t, err) + recs["simple"], _, _, _, err = (&simpleAWSRIPurchaseTool{}).recommendationFromArgs(validSimpleArgs()) + require.NoError(t, err) + recs["gcp"], _, _, _, err = gcpComputeEngineRecommendationFromArgs(validGCPCUDArgs()) + require.NoError(t, err) + + for name, rec := range recs { + setCaps(t, "1000", "1000", "1000") + over := rec + over.Count = 1001 + _, resolved, calls, err := runReal(t, over) + require.ErrorContains(t, err, EnvMaxCount, name) + assert.False(t, resolved, name) + assert.Zero(t, calls, name) + + atCap := rec + atCap.Count = 1000 + _, _, calls, err = runReal(t, atCap) + require.NoError(t, err, name) + assert.Equal(t, 1, calls, name) + } +} + +func TestSpendCapGCPMemory(t *testing.T) { + setCaps(t, "1000", "1", "64") + args := validGCPCUDArgs() + args.MemoryGB = 6000000 // small vcpu_count, huge memory: only MEMORY_GB bounds it + rec, _, _, _, err := gcpComputeEngineRecommendationFromArgs(args) + require.NoError(t, err) + _, resolved, calls, err := runReal(t, rec) + require.ErrorContains(t, err, EnvMaxMemoryGB) + assert.False(t, resolved) + assert.Zero(t, calls) + + args.MemoryGB = 64 + rec, _, _, _, err = gcpComputeEngineRecommendationFromArgs(args) + require.NoError(t, err) + _, _, calls, err = runReal(t, rec) + require.NoError(t, err) + assert.Equal(t, 1, calls) + + rec.Details = &common.ComputeDetails{MemoryGB: 1} + _, _, _, err = runReal(t, rec) + require.Error(t, err, "unexpected Details shape fails closed") +} + +func TestSavingsPlanPreviewShowsTotalCommitmentOffline(t *testing.T) { + setCaps(t, "", "", "") // preview needs no caps + rec := spRec(t, 50000) + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: rec.Region, Recommendation: rec, DryRun: true, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { + t.Fatal("preview must not resolve a client") + return nil, nil + }, + }) + require.NoError(t, err) + require.NotNil(t, resp.Cost) + assert.InDelta(t, 50000.0*8760*3, *resp.Cost, 0.5) +} + +func TestSavingsPlanCostDoesNotChangeIdempotencyKey(t *testing.T) { + rec := spRec(t, 10) + withCost := rec + withCost.CommitmentCost = 0 + assert.Equal(t, + idempotencyKeyFor("us-east-1", rec, "s", ""), + idempotencyKeyFor("us-east-1", withCost, "s", "")) +} From a701408e932ac21f8e50d5df483ff4dd6e74c981 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 9 Oct 2026 16:15:22 +0200 Subject: [PATCH 2/3] feat(mcp): price Savings Plan previews locally and document the caps Savings Plan previews report cost as the total commitment over the term (hourly x 8760 x years), computed without a provider call. README, server.json, the MCPB manifest and the changelog document the caps. Refs #47 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 12 ++++++++++++ README.md | 5 +++-- mcpb/manifest.json | 41 +++++++++++++++++++++++++++++++++++---- server.json | 21 ++++++++++++++++++++ tools/aws_savingsplans.go | 10 +++++++++- 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d53315..cc9dbfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Breaking + +- **Real purchases now require operator spend caps.** With + `CUDLY_MCP_ENABLE_REAL_PURCHASES=1`, every real purchase is refused unless + the relevant cap is set to a valid value: `CUDLY_MCP_MAX_COUNT` (RIs, and + vCPUs for GCP CUDs), `CUDLY_MCP_MAX_HOURLY_COMMITMENT` (Savings Plans) and + `CUDLY_MCP_MAX_MEMORY_GB` (GCP CUDs). Caps are per call and are not USD + caps. The MCPB bundle exposes them as optional settings. Previews are + unaffected. Savings Plan previews now report `cost` (total commitment over + the term), and Savings Plan audit lines now carry that figure as the + estimated cost. + ### Notices - **Federation IaC bundles downloaded before 2026-04-22 need to be diff --git a/README.md b/README.md index 0b81808..38d19c7 100644 --- a/README.md +++ b/README.md @@ -114,16 +114,17 @@ AZURE_SUBSCRIPTION_ID = "00000000-0000-0000-0000-000000000000" A typical session searches for a recommendation, previews the purchase, then executes it: 1. **Search**: call `cudly_search_recommendations` with `provider="aws"`, `service="ec2"`, `region="us-east-1"` to see what AWS Cost Explorer currently recommends reserving. -2. **Preview**: take a result's `region`/`resource_type`/`count` and call `cudly_aws_ec2_ri_purchase` with those values and `term_years`/`payment_option` of your choice. Leave `dry_run` at its default (`true`) -- the response validates your parameters without contacting AWS or spending anything, and reports `cost`/`on_demand_cost`/`estimated_savings`/`savings_percentage` only when a real figure is actually known (omitted otherwise, never a fabricated `0`). +2. **Preview**: take a result's `region`/`resource_type`/`count` and call `cudly_aws_ec2_ri_purchase` with those values and `term_years`/`payment_option` of your choice. Leave `dry_run` at its default (`true`) -- the response validates your parameters without contacting AWS or spending anything, and reports `cost`/`on_demand_cost`/`estimated_savings`/`savings_percentage` only when a real figure is actually known (omitted otherwise, never a fabricated `0`). Today only Savings Plan previews are priced (`cost` is the total commitment over the term, `hourly_commitment` x 8760 x `term_years`, computed locally); RI and CUD previews carry no price because the preview never contacts the provider. 3. **Execute**: once the preview looks right, call the same tool again with `dry_run=false, confirm=true`. This is the only combination that performs a real purchase, and only when the operator has also enabled `CUDLY_MCP_ENABLE_REAL_PURCHASES=1` on the server process; any other combination either previews or returns an explicit refusal error (see [Safety model](#safety-model)). Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_aws_rds_ri_purchase`, `cudly_azure_compute_ri_purchase`, `cudly_gcp_computeengine_cud_purchase`, ...) follows the identical dry_run-then-confirm pattern. Call `cudly_list_commitment_actions` at any point for the full, always-current list of tools, which ones can spend real money today, and 2-3 example prompts per tool. ## Safety model -- `dry_run` defaults to `true` on every purchase tool. A dry-run call never contacts the cloud provider and never spends money -- it only validates your parameters. It reports pricing (`cost`/`on_demand_cost`/`estimated_savings`/`savings_percentage`) only when a real figure is genuinely known; those fields are omitted, not zeroed, when it isn't. +- `dry_run` defaults to `true` on every purchase tool. A dry-run call never contacts the cloud provider and never spends money -- it only validates your parameters. It reports pricing (`cost`/`on_demand_cost`/`estimated_savings`/`savings_percentage`) only when a real figure is genuinely known; those fields are omitted, not zeroed, when it isn't. Only Savings Plan previews are priced (total commitment over the term, computed locally); RI and CUD previews show no price. - `dry_run=true` always previews, including when `confirm=true`. A real purchase requires **both** `dry_run=false` **and** `confirm=true`; `dry_run=false` with `confirm=false` is refused with a structured error, not silently downgraded to a preview. - A real purchase **also** requires the operator to have set `CUDLY_MCP_ENABLE_REAL_PURCHASES=1` (or `true`, case-insensitive) in the environment `cudly-mcp` was launched with. This is layered underneath `confirm`: `confirm` only proves the model asked to spend money, it does not prove the operator running this server wants it able to. The gate is unset (disabled) by default -- unset, empty, `0`, `false`, or any other value all disable real purchases -- so a fresh install cannot spend money until the operator explicitly opts in. When disabled, a `dry_run=false, confirm=true` call is refused before any provider or credential is touched, naming the flag to set. Dry runs are unaffected by this flag; they never spend regardless of its value. +- A real purchase also requires **operator-set spend caps**, read from the server's environment (never from tool arguments, which the model controls). Unset, empty or invalid caps refuse the purchase, naming the variable; there is no default and no "unlimited". `CUDLY_MCP_MAX_COUNT` (positive integer) bounds `count` for every RI purchase and `vcpu_count` for GCP CUDs. `CUDLY_MCP_MAX_HOURLY_COMMITMENT` (positive number, USD/hour) bounds Savings Plan `hourly_commitment`. `CUDLY_MCP_MAX_MEMORY_GB` (positive number) bounds `memory_gb` for GCP CUDs. Previews need no caps. **Limits you must understand:** the caps apply **per call**, not cumulatively, so a fresh `idempotency_nonce`, region or profile repeats the call and spends again; and the count cap is **not a USD cap**: unit prices differ by orders of magnitude (50 x `u-24tb1` at about $218/hour each for 3 years is roughly $286M under a `CUDLY_MCP_MAX_COUNT=50` that looks small). Choose caps with the most expensive resource type in mind. A USD cap and a cumulative budget are tracked as a follow-up. - A real purchase requires the **target account to be named**: `aws_profile` (AWS) or `azure_subscription_id` (Azure), either as the tool argument or via the matching environment variable the provider itself reads (`AWS_PROFILE`, `AZURE_SUBSCRIPTION_ID`). **GCP has no environment fallback, so `gcp_project_id` is required for a real CUD purchase.** That is deliberate: nothing in `providers/gcp` reads `GOOGLE_CLOUD_PROJECT` or `CLOUDSDK_CORE_PROJECT`, and with no project configured the provider falls back to the *first active project* in your `ListProjects` response -- an artifact of IAM visibility and API ordering rather than a project anyone chose, which is not a defensible default for spending money. Pointing the scope at an environment variable the provider ignores would be worse still: the idempotency token would name one project while the commitment landed in another. If neither is set, the real purchase is refused before any credential is touched, naming the argument to pass. **Dry runs do not require it** -- you can price a purchase without naming an account. This is not bookkeeping: the account is folded into the idempotency token, so leaving it to ambient credentials on one call and naming it explicitly on the next derives two *different* tokens for the *same* account. Every provider's dedupe is token-keyed, so the second call's lookup would miss and buy a second commitment. Refusing an undeterminable account makes that aliasing unreachable. Naming an account explicitly and inheriting the same value from the environment always agree, so they still dedupe normally. - Every money-affecting parameter (region, resource type, count, term, payment option, and any provider-specific dimension such as RDS's `az_config`) is validated against an explicit enum or non-empty check before anything is built or sent. There is no silent default for a value that materially changes what gets purchased. - Every real purchase is tagged with a source identifying it came from this MCP server (never a user-suppliable string) and a deterministic idempotency token derived from the request's own parameters. By default, retrying an identical tool call -- however long after the original, and regardless of any clock boundary -- always derives the same token, so the provider dedupes the retry instead of buying twice; this is a fail-safe default, since the worst case of a false dedupe is a skipped intentional repeat, never a double purchase. To deliberately make a second, otherwise-identical purchase (e.g. "buy 3 RIs now" and "buy 3 more next week"), pass a fresh `idempotency_nonce` value on the second call; passing the same nonce on a retry of that same call still dedupes correctly. diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 0fa0582..3103c29 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -13,7 +13,10 @@ }, "license": "OSL-3.0", "compatibility": { - "platforms": ["darwin", "linux"] + "platforms": [ + "darwin", + "linux" + ] }, "server": { "type": "binary", @@ -22,21 +25,30 @@ "command": "${__dirname}/server/linux-launch.sh", "args": [], "env": { - "CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}" + "CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}", + "CUDLY_MCP_MAX_COUNT": "${user_config.max_count}", + "CUDLY_MCP_MAX_HOURLY_COMMITMENT": "${user_config.max_hourly_commitment}", + "CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}" }, "platform_overrides": { "darwin": { "command": "${__dirname}/server/darwin-launch.sh", "args": [], "env": { - "CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}" + "CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}", + "CUDLY_MCP_MAX_COUNT": "${user_config.max_count}", + "CUDLY_MCP_MAX_HOURLY_COMMITMENT": "${user_config.max_hourly_commitment}", + "CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}" } }, "linux": { "command": "${__dirname}/server/linux-launch.sh", "args": [], "env": { - "CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}" + "CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}", + "CUDLY_MCP_MAX_COUNT": "${user_config.max_count}", + "CUDLY_MCP_MAX_HOURLY_COMMITMENT": "${user_config.max_hourly_commitment}", + "CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}" } } } @@ -49,6 +61,27 @@ "description": "DANGER: turns on cudly-mcp's ability to execute REAL, money-spending cloud purchases (AWS Reserved Instances, Savings Plans, Azure Reservations, GCP CUDs) when a client explicitly requests dry_run=false and confirm=true. Leave this off until you have exercised the dry-run path and are ready to let it spend real money.", "default": false, "required": false + }, + "max_count": { + "type": "number", + "title": "Maximum count per purchase call", + "description": "Per-call ceiling on instances/nodes (or vCPUs for GCP CUDs) for a real purchase. Required before any real purchase: leave it empty and purchases are refused. Applies per call, not cumulatively, and is not a USD cap.", + "min": 1, + "required": false + }, + "max_hourly_commitment": { + "type": "number", + "title": "Maximum hourly commitment (USD/hour)", + "description": "Per-call ceiling on a Savings Plan hourly commitment. Required before any real Savings Plan purchase. Applies per call, not cumulatively.", + "min": 1, + "required": false + }, + "max_memory_gb": { + "type": "number", + "title": "Maximum committed memory (GB)", + "description": "Per-call ceiling on memory for a GCP CUD. Required before any real GCP CUD purchase. Applies per call, not cumulatively.", + "min": 1, + "required": false } }, "privacy_policies": [ diff --git a/server.json b/server.json index cbfc6e8..39fe494 100644 --- a/server.json +++ b/server.json @@ -24,6 +24,27 @@ "isSecret": false, "format": "string" }, + { + "name": "CUDLY_MCP_MAX_COUNT", + "description": "Positive integer. Per-call ceiling on count (RIs) and vcpu_count (GCP CUDs) for real purchases. Required for any real purchase: unset, empty or invalid refuses it. Not a USD cap and not cumulative; see README 'Safety model'.", + "isRequired": false, + "isSecret": false, + "format": "string" + }, + { + "name": "CUDLY_MCP_MAX_HOURLY_COMMITMENT", + "description": "Positive number (USD/hour). Per-call ceiling on Savings Plan hourly_commitment. Required for real Savings Plan purchases: unset, empty or invalid refuses it. Not cumulative.", + "isRequired": false, + "isSecret": false, + "format": "string" + }, + { + "name": "CUDLY_MCP_MAX_MEMORY_GB", + "description": "Positive number. Per-call ceiling on memory_gb for GCP CUD purchases. Required for real GCP CUD purchases: unset, empty or invalid refuses it. Not cumulative.", + "isRequired": false, + "isSecret": false, + "format": "string" + }, { "name": "CUDLY_MCP_AUDIT_LOG", "description": "Overrides the local JSONL purchase audit path after trimming. When unset, writes to $XDG_STATE_HOME/cudly/mcp-audit.jsonl; if XDG_STATE_HOME is unset, empty, or relative, it is ignored and the fallback is ~/.local/state/cudly/mcp-audit.jsonl. An empty or whitespace-only CUDLY_MCP_AUDIT_LOG value disables auditing.", diff --git a/tools/aws_savingsplans.go b/tools/aws_savingsplans.go index 29f007c..3ec2acb 100644 --- a/tools/aws_savingsplans.go +++ b/tools/aws_savingsplans.go @@ -34,6 +34,10 @@ const awsSavingsPlansPurchaseDescription = "Purchase an AWS Savings Plan (Comput // SageMaker, and Database plans are global, and cmd/multi_service_helpers.go // already establishes this same "single query, us-east-1" convention for // account-level Savings Plans recommendations. +// savingsPlanHoursPerYear is 365 days of hours, matching +// providers/aws/services/savingsplans hoursInTerm. +const savingsPlanHoursPerYear = 24 * 365 + const savingsPlansAccountLevelRegion = "us-east-1" // savingsPlansPurchaseArgs is the input schema for @@ -49,7 +53,7 @@ type savingsPlansPurchaseArgs struct { Region string `json:"region,omitempty" jsonschema:"AWS region; required for sp_type=EC2Instance, ignored for account-level plan types"` AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional; set to a fresh value to authorize a purchase that is otherwise identical to a previous one (e.g. buy 3 more RIs with the same parameters); leave empty (the default) so retries with identical parameters dedupe and never double-buy"` - HourlyCommitment float64 `json:"hourly_commitment" jsonschema:"USD/hour commitment amount, must be > 0"` + HourlyCommitment float64 `json:"hourly_commitment" jsonschema:"USD/hour commitment amount, must be > 0; real purchases are capped by the operator (CUDLY_MCP_MAX_HOURLY_COMMITMENT)"` TermYears int `json:"term_years" jsonschema:"commitment length in years"` } @@ -229,6 +233,10 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo Term: term.RecommendationTerm(), PaymentOption: string(paymentOption), Details: details, + // Total commitment over the term, computed locally (no provider call) + // with the 365-day year the savingsplans client also uses. Lets the + // offline preview show a price for this dollar-denominated product. + CommitmentCost: args.HourlyCommitment * savingsPlanHoursPerYear * float64(term), } dryRun, confirm = ResolveDryRunConfirm(args.DryRun, args.Confirm) From 1418f8d3e451471b54d4c7b7d1f0915907323f67 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 9 Oct 2026 16:29:29 +0200 Subject: [PATCH 3/3] docs(mcp): fix const doc attachment and link the USD-cap follow-up Move savingsPlanHoursPerYear out of savingsPlansAccountLevelRegion's doc comment and link #51 from the README. Refs #47 Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- tools/aws_savingsplans.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 38d19c7..5494fca 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_ - `dry_run` defaults to `true` on every purchase tool. A dry-run call never contacts the cloud provider and never spends money -- it only validates your parameters. It reports pricing (`cost`/`on_demand_cost`/`estimated_savings`/`savings_percentage`) only when a real figure is genuinely known; those fields are omitted, not zeroed, when it isn't. Only Savings Plan previews are priced (total commitment over the term, computed locally); RI and CUD previews show no price. - `dry_run=true` always previews, including when `confirm=true`. A real purchase requires **both** `dry_run=false` **and** `confirm=true`; `dry_run=false` with `confirm=false` is refused with a structured error, not silently downgraded to a preview. - A real purchase **also** requires the operator to have set `CUDLY_MCP_ENABLE_REAL_PURCHASES=1` (or `true`, case-insensitive) in the environment `cudly-mcp` was launched with. This is layered underneath `confirm`: `confirm` only proves the model asked to spend money, it does not prove the operator running this server wants it able to. The gate is unset (disabled) by default -- unset, empty, `0`, `false`, or any other value all disable real purchases -- so a fresh install cannot spend money until the operator explicitly opts in. When disabled, a `dry_run=false, confirm=true` call is refused before any provider or credential is touched, naming the flag to set. Dry runs are unaffected by this flag; they never spend regardless of its value. -- A real purchase also requires **operator-set spend caps**, read from the server's environment (never from tool arguments, which the model controls). Unset, empty or invalid caps refuse the purchase, naming the variable; there is no default and no "unlimited". `CUDLY_MCP_MAX_COUNT` (positive integer) bounds `count` for every RI purchase and `vcpu_count` for GCP CUDs. `CUDLY_MCP_MAX_HOURLY_COMMITMENT` (positive number, USD/hour) bounds Savings Plan `hourly_commitment`. `CUDLY_MCP_MAX_MEMORY_GB` (positive number) bounds `memory_gb` for GCP CUDs. Previews need no caps. **Limits you must understand:** the caps apply **per call**, not cumulatively, so a fresh `idempotency_nonce`, region or profile repeats the call and spends again; and the count cap is **not a USD cap**: unit prices differ by orders of magnitude (50 x `u-24tb1` at about $218/hour each for 3 years is roughly $286M under a `CUDLY_MCP_MAX_COUNT=50` that looks small). Choose caps with the most expensive resource type in mind. A USD cap and a cumulative budget are tracked as a follow-up. +- A real purchase also requires **operator-set spend caps**, read from the server's environment (never from tool arguments, which the model controls). Unset, empty or invalid caps refuse the purchase, naming the variable; there is no default and no "unlimited". `CUDLY_MCP_MAX_COUNT` (positive integer) bounds `count` for every RI purchase and `vcpu_count` for GCP CUDs. `CUDLY_MCP_MAX_HOURLY_COMMITMENT` (positive number, USD/hour) bounds Savings Plan `hourly_commitment`. `CUDLY_MCP_MAX_MEMORY_GB` (positive number) bounds `memory_gb` for GCP CUDs. Previews need no caps. **Limits you must understand:** the caps apply **per call**, not cumulatively, so a fresh `idempotency_nonce`, region or profile repeats the call and spends again; and the count cap is **not a USD cap**: unit prices differ by orders of magnitude (50 x `u-24tb1` at about $218/hour each for 3 years is roughly $286M under a `CUDLY_MCP_MAX_COUNT=50` that looks small). Choose caps with the most expensive resource type in mind. A USD cap and a cumulative budget are tracked in [#51](https://github.com/LeanerCloud/cloud-commitments-mcp/issues/51). - A real purchase requires the **target account to be named**: `aws_profile` (AWS) or `azure_subscription_id` (Azure), either as the tool argument or via the matching environment variable the provider itself reads (`AWS_PROFILE`, `AZURE_SUBSCRIPTION_ID`). **GCP has no environment fallback, so `gcp_project_id` is required for a real CUD purchase.** That is deliberate: nothing in `providers/gcp` reads `GOOGLE_CLOUD_PROJECT` or `CLOUDSDK_CORE_PROJECT`, and with no project configured the provider falls back to the *first active project* in your `ListProjects` response -- an artifact of IAM visibility and API ordering rather than a project anyone chose, which is not a defensible default for spending money. Pointing the scope at an environment variable the provider ignores would be worse still: the idempotency token would name one project while the commitment landed in another. If neither is set, the real purchase is refused before any credential is touched, naming the argument to pass. **Dry runs do not require it** -- you can price a purchase without naming an account. This is not bookkeeping: the account is folded into the idempotency token, so leaving it to ambient credentials on one call and naming it explicitly on the next derives two *different* tokens for the *same* account. Every provider's dedupe is token-keyed, so the second call's lookup would miss and buy a second commitment. Refusing an undeterminable account makes that aliasing unreachable. Naming an account explicitly and inheriting the same value from the environment always agree, so they still dedupe normally. - Every money-affecting parameter (region, resource type, count, term, payment option, and any provider-specific dimension such as RDS's `az_config`) is validated against an explicit enum or non-empty check before anything is built or sent. There is no silent default for a value that materially changes what gets purchased. - Every real purchase is tagged with a source identifying it came from this MCP server (never a user-suppliable string) and a deterministic idempotency token derived from the request's own parameters. By default, retrying an identical tool call -- however long after the original, and regardless of any clock boundary -- always derives the same token, so the provider dedupes the retry instead of buying twice; this is a fail-safe default, since the worst case of a false dedupe is a skipped intentional repeat, never a double purchase. To deliberately make a second, otherwise-identical purchase (e.g. "buy 3 RIs now" and "buy 3 more next week"), pass a fresh `idempotency_nonce` value on the second call; passing the same nonce on a retry of that same call still dedupes correctly. diff --git a/tools/aws_savingsplans.go b/tools/aws_savingsplans.go index 3ec2acb..994ee48 100644 --- a/tools/aws_savingsplans.go +++ b/tools/aws_savingsplans.go @@ -29,15 +29,15 @@ const awsSavingsPlansPurchaseDescription = "Purchase an AWS Savings Plan (Comput "payment_option=no-upfront; AWS does not offer a 3-year Database Savings Plan or all-upfront/" + "partial-upfront billing for it." +// savingsPlanHoursPerYear is 365 days of hours, matching +// providers/aws/services/savingsplans hoursInTerm. +const savingsPlanHoursPerYear = 24 * 365 + // savingsPlansAccountLevelRegion is the region used to resolve the account- // level Savings Plans service client when the caller omits region -- Compute, // SageMaker, and Database plans are global, and cmd/multi_service_helpers.go // already establishes this same "single query, us-east-1" convention for // account-level Savings Plans recommendations. -// savingsPlanHoursPerYear is 365 days of hours, matching -// providers/aws/services/savingsplans hoursInTerm. -const savingsPlanHoursPerYear = 24 * 365 - const savingsPlansAccountLevelRegion = "us-east-1" // savingsPlansPurchaseArgs is the input schema for