From fd54e20106d60750bd6c95aa1a384ff25ac6fa62 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 22:29:25 +0100 Subject: [PATCH 1/2] test: cover partial-upfront guard boundary and pin approved_currency enum Add partial-upfront cases (FixedPrice plus recurring charge) to the real-client approved-upfront boundary tests for RDS, OpenSearch, Redshift and MemoryDB, pin the simple tools' approved_currency USD enum in the registered MCP schema, and rename the RDS no-upfront dry-run test to say what it covers. README now states that a rise of more than 5% between preview and purchase is refused. Closes #129 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- tools/aws_rds_ri_test.go | 2 +- tools/aws_rds_ri_upfront_guard_test.go | 72 ++++++++++++++--------- tools/aws_simple_ri_test.go | 32 ++++++++++ tools/aws_simple_ri_upfront_guard_test.go | 63 ++++++++++++-------- 5 files changed, 116 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 701f72d..392a832 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_ ## 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. Only Savings Plan previews are priced (total commitment over the term, computed locally); RI and CUD previews show no price. The one exception is the `partial-upfront`/`all-upfront` preview of an EC2, RDS, OpenSearch, Redshift or MemoryDB RI, which makes a read-only AWS offering lookup and returns `approved_upfront`/`approved_currency` (see the [Worked example](#worked-example)); it still never spends money. -- A real `partial-upfront` or `all-upfront` EC2, RDS, OpenSearch, Redshift or MemoryDB RI purchase requires `approved_upfront` and `approved_currency` copied from its preview. They are passed to the provider as the approved upfront, and the provider refuses to buy an offering whose upfront (`FixedPrice` x `count`) exceeds it by more than 5%, so a price change between preview and purchase cannot buy at the higher price. The figure is caller-supplied: it bounds price drift, not a deliberately inflated figure, which the USD cap and budget below still bound. `approved_upfront` must be a positive number and `approved_currency` must be `USD`; both are rejected for `no-upfront`. +- A real `partial-upfront` or `all-upfront` EC2, RDS, OpenSearch, Redshift or MemoryDB RI purchase requires `approved_upfront` and `approved_currency` copied from its preview. They are passed to the provider as the approved upfront, and the provider refuses to buy an offering whose upfront (`FixedPrice` x `count`) exceeds it by more than 5%, so a rise of more than 5% between preview and purchase is refused. The figure is caller-supplied: it bounds price drift, not a deliberately inflated figure, which the USD cap and budget below still bound. `approved_upfront` must be a positive number and `approved_currency` must be `USD`; both are rejected for `no-upfront`. - `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_COMMITMENT_USD` (positive number, USD) is required for every real purchase and bounds the **total USD one call commits over the full term**: it is read from the provider's own offering lookup just before buying (Savings Plans: hourly commitment x 8760 x years, computed locally), so executing needs credentials and network access for that lookup. Only USD is supported; a purchase whose price is missing, zero, non-USD or errors is refused rather than assumed cheap. Real GCP CUD purchases are refused until the `cloud-commitments-go` pin includes the CUD pricing fix (go#321). The Azure figure is the Retail Prices list price, not a negotiated rate. The lookup and the purchase are separate calls, so the price can change between them. `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. `CUDLY_MCP_BUDGET_USD` (positive number, USD) is also required and is a **cumulative** budget: the USD totals of all real purchases in this server process may not exceed it, so a fresh `idempotency_nonce`, region or profile cannot repeat a purchase past it. **Limits you must understand:** the other caps apply **per call**. By default the budget is held in memory, so it resets when the server restarts and is not shared across processes or MCP clients; set `CUDLY_MCP_BUDGET_STATE` to persist it (see [Persisted budget](#persisted-budget)). Either way it cannot see purchases made in the cloud console, CLI or web. A purchase whose outcome is unknown, or that fails after the provider was called, counts as spent. Two cases are released: a clean no-op, and a purchase the provider refused before any purchase call (for example the approved-upfront price guard), which it marks with `common.ErrRefusedBeforePurchase`. An existing commitment the library adopts on an idempotent retry is released, but the retry still needs headroom for its own total at the time of the call. Choose the USD cap as the most one call may ever spend. diff --git a/tools/aws_rds_ri_test.go b/tools/aws_rds_ri_test.go index c2c91d4..9b9440f 100644 --- a/tools/aws_rds_ri_test.go +++ b/tools/aws_rds_ri_test.go @@ -112,7 +112,7 @@ func TestAWSRDSRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { assert.Contains(t, err.Error(), "confirm=true") } -func TestAWSRDSRIPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { +func TestAWSRDSRIPurchaseHandleNoUpfrontDryRunNeverCallsProvider(t *testing.T) { t.Parallel() resolveCalled := false tool := &awsRDSRIPurchaseTool{ diff --git a/tools/aws_rds_ri_upfront_guard_test.go b/tools/aws_rds_ri_upfront_guard_test.go index 33589df..1c613ff 100644 --- a/tools/aws_rds_ri_upfront_guard_test.go +++ b/tools/aws_rds_ri_upfront_guard_test.go @@ -24,8 +24,18 @@ func allUpfrontRDSFake(price float64) *fakeRDSAPI { return &fakeRDSAPI{offerings: []rdstypes.ReservedDBInstancesOffering{o}} } -func guardRDSArgs(nonce string, upfront *float64, currency string) rdsRIPurchaseArgs { - args := upfrontRDSArgs(PaymentOptionAllUpfront, nonce) +// partialUpfrontRDSFake serves one Partial Upfront offering: FixedPrice plus +// a recurring hourly charge, which the guard must ignore. +func partialUpfrontRDSFake(price float64) *fakeRDSAPI { + o := rdsOffering("off-partial-upfront", "postgresql") + o.OfferingType = aws.String("Partial Upfront") + o.FixedPrice = aws.Float64(price) + o.RecurringCharges = []rdstypes.RecurringCharge{{RecurringChargeAmount: aws.Float64(0.05), RecurringChargeFrequency: aws.String("Hourly")}} + return &fakeRDSAPI{offerings: []rdstypes.ReservedDBInstancesOffering{o}} +} + +func guardRDSArgs(payment PaymentOption, nonce string, upfront *float64, currency string) rdsRIPurchaseArgs { + args := upfrontRDSArgs(payment, nonce) args.Count = 1 return confirmedRDS(args, upfront, currency) } @@ -34,31 +44,37 @@ func guardRDSArgs(nonce string, upfront *float64, currency string) rdsRIPurchase // approved-upfront check (go#611): approved 1000 allows an offering up to // 1050.00 (+5%); 1050.01 is refused before any purchase call. func TestRDSUpfrontGuardBoundaryOnRealClient(t *testing.T) { - for _, tc := range []struct { - name string - wantErr string - price float64 - buys bool - }{ - {"at approved", "", 1000, true}, - {"at tolerance edge", "", 1050.00, true}, - {"just above tolerance", "exceeds the approved upfront", 1050.01, false}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Setenv(EnvAuditLog, filepath.Join(t.TempDir(), "audit.jsonl")) - fake := allUpfrontRDSFake(tc.price) - _, resp, err := rdsToolWithFake(fake).handle(context.Background(), nil, - guardRDSArgs("guard-"+tc.name, floatPtr(1000), "USD")) - if tc.buys { - require.NoError(t, err) - assert.True(t, resp.Success) - assert.Equal(t, 1, fake.purchaseCalls) - return - } - require.Error(t, err) - assert.Contains(t, err.Error(), tc.wantErr) - assert.Zero(t, fake.purchaseCalls, "a refused guard must never reach Purchase") - }) + fakes := map[PaymentOption]func(float64) *fakeRDSAPI{ + PaymentOptionAllUpfront: allUpfrontRDSFake, + PaymentOptionPartialUpfront: partialUpfrontRDSFake, + } + for payment, newFake := range fakes { + for _, tc := range []struct { + name string + wantErr string + price float64 + buys bool + }{ + {"at approved", "", 1000, true}, + {"at tolerance edge", "", 1050.00, true}, + {"just above tolerance", "exceeds the approved upfront", 1050.01, false}, + } { + t.Run(string(payment)+"/"+tc.name, func(t *testing.T) { + t.Setenv(EnvAuditLog, filepath.Join(t.TempDir(), "audit.jsonl")) + fake := newFake(tc.price) + _, resp, err := rdsToolWithFake(fake).handle(context.Background(), nil, + guardRDSArgs(payment, "guard-"+string(payment)+tc.name, floatPtr(1000), "USD")) + if tc.buys { + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, 1, fake.purchaseCalls) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + assert.Zero(t, fake.purchaseCalls, "a refused guard must never reach Purchase") + }) + } } } @@ -89,7 +105,7 @@ func TestRDSUpfrontUnusableFiguresNeverReachClient(t *testing.T) { } { t.Run(name, func(t *testing.T) { fake := allUpfrontRDSFake(1000) - _, _, err := rdsToolWithFake(fake).handle(context.Background(), nil, guardRDSArgs("unusable-"+name, tc.upfront, tc.currency)) + _, _, err := rdsToolWithFake(fake).handle(context.Background(), nil, guardRDSArgs(PaymentOptionAllUpfront, "unusable-"+name, tc.upfront, tc.currency)) require.Error(t, err) assert.Zero(t, fake.describeCalls+fake.purchaseCalls) }) diff --git a/tools/aws_simple_ri_test.go b/tools/aws_simple_ri_test.go index 40a5d50..50f9a47 100644 --- a/tools/aws_simple_ri_test.go +++ b/tools/aws_simple_ri_test.go @@ -2,8 +2,10 @@ package tools import ( "context" + "encoding/json" "testing" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -217,3 +219,33 @@ func TestSimpleAWSRIPurchaseHandleRealPurchaseCallsCorrectService(t *testing.T) }) } } + +func TestSimpleAWSRIPurchaseRegisteredSchemaPinsApprovedCurrencyUSD(t *testing.T) { + t.Parallel() + for product, ctor := range simpleToolConstructors() { + t.Run(product, func(t *testing.T) { + srv := mcp.NewServer(&mcp.Implementation{Name: "t", Version: "0"}, nil) + require.NoError(t, ctor().Register(srv)) + ct, st := mcp.NewInMemoryTransports() + ctx := context.Background() + ss, err := srv.Connect(ctx, st, nil) + require.NoError(t, err) + defer ss.Close() + cs, err := mcp.NewClient(&mcp.Implementation{Name: "c", Version: "0"}, nil).Connect(ctx, ct, nil) + require.NoError(t, err) + defer cs.Close() + list, err := cs.ListTools(ctx, nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + raw, err := json.Marshal(list.Tools[0].InputSchema) + require.NoError(t, err) + var parsed struct { + Properties map[string]struct { + Enum []any `json:"enum"` + } `json:"properties"` + } + require.NoError(t, json.Unmarshal(raw, &parsed)) + assert.Equal(t, []any{"USD"}, parsed.Properties["approved_currency"].Enum) + }) + } +} diff --git a/tools/aws_simple_ri_upfront_guard_test.go b/tools/aws_simple_ri_upfront_guard_test.go index 97a8d0f..fd248d0 100644 --- a/tools/aws_simple_ri_upfront_guard_test.go +++ b/tools/aws_simple_ri_upfront_guard_test.go @@ -111,6 +111,10 @@ func guardOpenSearch(payment PaymentOption, fixedPrice float64) guardService { FixedPrice: aws.Float64(fixedPrice), CurrencyCode: aws.String("USD"), PaymentOption: ostypes.ReservedInstancePaymentOptionAllUpfront, } + if payment == PaymentOptionPartialUpfront { + o.PaymentOption = ostypes.ReservedInstancePaymentOptionPartialUpfront + o.RecurringCharges = []ostypes.RecurringCharge{{RecurringChargeAmount: aws.Float64(0.05), RecurringChargeFrequency: aws.String("Hourly")}} + } if payment == PaymentOptionNoUpfront { o.PaymentOption = ostypes.ReservedInstancePaymentOptionNoUpfront o.RecurringCharges = []ostypes.RecurringCharge{{RecurringChargeAmount: aws.Float64(0.05), RecurringChargeFrequency: aws.String("Hourly")}} @@ -128,6 +132,9 @@ func guardRedshift(payment PaymentOption, fixedPrice float64) guardService { ReservedNodeOfferingType: rstypes.ReservedNodeOfferingTypeRegular, FixedPrice: aws.Float64(fixedPrice), CurrencyCode: aws.String("USD"), } + if payment == PaymentOptionPartialUpfront { + o.RecurringCharges = []rstypes.RecurringCharge{{RecurringChargeAmount: aws.Float64(0.05), RecurringChargeFrequency: aws.String("Hourly")}} + } if payment == PaymentOptionNoUpfront { o.FixedPrice = aws.Float64(0) o.RecurringCharges = []rstypes.RecurringCharge{{RecurringChargeAmount: aws.Float64(0.05), RecurringChargeFrequency: aws.String("Hourly")}} @@ -143,6 +150,10 @@ func guardMemoryDBIn(region string, payment PaymentOption, fixedPrice float64) g ReservedNodesOfferingId: aws.String("off-mdb"), NodeType: aws.String("r6g.large"), Duration: guardOneYearSeconds, OfferingType: aws.String("All Upfront"), FixedPrice: fixedPrice, } + if payment == PaymentOptionPartialUpfront { + o.OfferingType = aws.String("Partial Upfront") + o.RecurringCharges = []mdbtypes.RecurringCharge{{RecurringChargeAmount: 0.05, RecurringChargeFrequency: aws.String("Hourly")}} + } if payment == PaymentOptionNoUpfront { o.OfferingType = aws.String("No Upfront") o.FixedPrice = 0 @@ -179,31 +190,33 @@ func guardEnv(t *testing.T) { func TestSimpleUpfrontGuardBoundaryOnRealClients(t *testing.T) { for product, build := range guardServices { - for _, tc := range []struct { - name string - price float64 - buys bool - }{ - {"at approved", 1000, true}, - {"at tolerance edge", 1050.00, true}, - {"just above tolerance", 1050.01, false}, - } { - t.Run(product+"/"+tc.name, func(t *testing.T) { - guardEnv(t) - svc := build(PaymentOptionAllUpfront, tc.price) - tool, _ := simpleToolOver(svc.ctor, svc.client) - _, resp, err := tool.handle(context.Background(), nil, - guardArgs("guard-"+product+tc.name, PaymentOptionAllUpfront, floatPtr(1000), "USD")) - if tc.buys { - require.NoError(t, err) - assert.True(t, resp.Success) - assert.Equal(t, 1, svc.history()) - return - } - require.Error(t, err) - assert.Contains(t, err.Error(), "exceeds the approved upfront") - assert.Zero(t, svc.history(), "a refused guard must never reach Purchase") - }) + for _, payment := range []PaymentOption{PaymentOptionAllUpfront, PaymentOptionPartialUpfront} { + for _, tc := range []struct { + name string + price float64 + buys bool + }{ + {"at approved", 1000, true}, + {"at tolerance edge", 1050.00, true}, + {"just above tolerance", 1050.01, false}, + } { + t.Run(product+"/"+string(payment)+"/"+tc.name, func(t *testing.T) { + guardEnv(t) + svc := build(payment, tc.price) + tool, _ := simpleToolOver(svc.ctor, svc.client) + _, resp, err := tool.handle(context.Background(), nil, + guardArgs("guard-"+product+string(payment)+tc.name, payment, floatPtr(1000), "USD")) + if tc.buys { + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, 1, svc.history()) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds the approved upfront") + assert.Zero(t, svc.history(), "a refused guard must never reach Purchase") + }) + } } } } From 609e7b125e4cb4983422994fda5c3aa47569a4d7 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 22:35:40 +0100 Subject: [PATCH 2/2] test: rename simple no-upfront dry-run test and note cent comparison The simple-tool dry-run test forces no-upfront, so its name overclaimed. README now says the 5% rise comparison is done to the cent. Closes #129 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- tools/aws_simple_ri_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 392a832..4ca9019 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_ ## 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. Only Savings Plan previews are priced (total commitment over the term, computed locally); RI and CUD previews show no price. The one exception is the `partial-upfront`/`all-upfront` preview of an EC2, RDS, OpenSearch, Redshift or MemoryDB RI, which makes a read-only AWS offering lookup and returns `approved_upfront`/`approved_currency` (see the [Worked example](#worked-example)); it still never spends money. -- A real `partial-upfront` or `all-upfront` EC2, RDS, OpenSearch, Redshift or MemoryDB RI purchase requires `approved_upfront` and `approved_currency` copied from its preview. They are passed to the provider as the approved upfront, and the provider refuses to buy an offering whose upfront (`FixedPrice` x `count`) exceeds it by more than 5%, so a rise of more than 5% between preview and purchase is refused. The figure is caller-supplied: it bounds price drift, not a deliberately inflated figure, which the USD cap and budget below still bound. `approved_upfront` must be a positive number and `approved_currency` must be `USD`; both are rejected for `no-upfront`. +- A real `partial-upfront` or `all-upfront` EC2, RDS, OpenSearch, Redshift or MemoryDB RI purchase requires `approved_upfront` and `approved_currency` copied from its preview. They are passed to the provider as the approved upfront, and the provider refuses to buy an offering whose upfront (`FixedPrice` x `count`) exceeds it by more than 5%, so a rise of more than 5% between preview and purchase is refused (compared to the cent). The figure is caller-supplied: it bounds price drift, not a deliberately inflated figure, which the USD cap and budget below still bound. `approved_upfront` must be a positive number and `approved_currency` must be `USD`; both are rejected for `no-upfront`. - `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_COMMITMENT_USD` (positive number, USD) is required for every real purchase and bounds the **total USD one call commits over the full term**: it is read from the provider's own offering lookup just before buying (Savings Plans: hourly commitment x 8760 x years, computed locally), so executing needs credentials and network access for that lookup. Only USD is supported; a purchase whose price is missing, zero, non-USD or errors is refused rather than assumed cheap. Real GCP CUD purchases are refused until the `cloud-commitments-go` pin includes the CUD pricing fix (go#321). The Azure figure is the Retail Prices list price, not a negotiated rate. The lookup and the purchase are separate calls, so the price can change between them. `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. `CUDLY_MCP_BUDGET_USD` (positive number, USD) is also required and is a **cumulative** budget: the USD totals of all real purchases in this server process may not exceed it, so a fresh `idempotency_nonce`, region or profile cannot repeat a purchase past it. **Limits you must understand:** the other caps apply **per call**. By default the budget is held in memory, so it resets when the server restarts and is not shared across processes or MCP clients; set `CUDLY_MCP_BUDGET_STATE` to persist it (see [Persisted budget](#persisted-budget)). Either way it cannot see purchases made in the cloud console, CLI or web. A purchase whose outcome is unknown, or that fails after the provider was called, counts as spent. Two cases are released: a clean no-op, and a purchase the provider refused before any purchase call (for example the approved-upfront price guard), which it marks with `common.ErrRefusedBeforePurchase`. An existing commitment the library adopts on an idempotent retry is released, but the retry still needs headroom for its own total at the time of the call. Choose the USD cap as the most one call may ever spend. diff --git a/tools/aws_simple_ri_test.go b/tools/aws_simple_ri_test.go index 50f9a47..3d6ec62 100644 --- a/tools/aws_simple_ri_test.go +++ b/tools/aws_simple_ri_test.go @@ -163,7 +163,7 @@ func TestSimpleAWSRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { } } -func TestSimpleAWSRIPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { +func TestSimpleAWSRIPurchaseHandleNoUpfrontDryRunNeverCallsProvider(t *testing.T) { t.Parallel() for product, ctor := range simpleToolConstructors() { t.Run(product, func(t *testing.T) {