Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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. The budget counts whole cents: each purchase total rounds up to the cent and the budget rounds down, so rounding never admits more than the budget.
Expand Down
2 changes: 1 addition & 1 deletion tools/aws_rds_ri_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
72 changes: 44 additions & 28 deletions tools/aws_rds_ri_upfront_guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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")
})
}
}
}

Expand Down Expand Up @@ -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)
})
Expand Down
34 changes: 33 additions & 1 deletion tools/aws_simple_ri_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -161,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) {
Expand Down Expand Up @@ -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)
})
}
}
63 changes: 38 additions & 25 deletions tools/aws_simple_ri_upfront_guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")}}
Expand All @@ -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")}}
Expand All @@ -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
Expand Down Expand Up @@ -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")
})
}
}
}
}
Expand Down
Loading