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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Fixed

- The cumulative budget (`CUDLY_MCP_BUDGET_USD`, in memory and in
`CUDLY_MCP_BUDGET_STATE`) adds and compares whole cents instead of float64
dollars, so a residual such as 0.1 + 0.2 = 0.30000000000000004 no longer
refuses a purchase that exactly fits. Each purchase total rounds up to the cent
and the budget rounds down, so spend is never undercounted. The state file
format is unchanged; an older file's fraction of a cent reads rounded up, and a
`null` amount is now corrupt instead of 0 (#82).
- A partial- or all-upfront dry run whose offering cannot be priced now writes an
audit record (`status: "error"`, `dry_run: true`) instead of returning without
one, as the README promises for previews (Refs #129).
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_
- 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`.
- `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.
- 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.
- 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.
Expand Down Expand Up @@ -179,7 +179,7 @@ Set `CUDLY_MCP_BUDGET_STATE` to a file path to keep the cumulative budget across

- **Window:** one calendar month in UTC. The first purchase check in a new month starts `spent_usd` again from 0; there is no other automatic reset. Reservations still unsettled carry into the new month and keep counting until settled or reset. A file dated after the current UTC month (clock skew) refuses purchases and is not rewritten.
- **File:** JSON, written atomically (temp file, fsync, rename) under an exclusive lock on `<path>.lock`, so concurrent servers cannot overspend together. The parent directory is created `0700` and the files `0600`, as for the audit log. Locking needs Linux or macOS; elsewhere a configured file refuses purchases.
- **Format:** `{"month": "2026-10", "reservations": [{"id": "...", "usd": 40}], "spent_usd": 60, "version": 1}`. A reservation is written before the purchase call and settled after. One still present (a crash or timeout mid-purchase) counts as spent.
- **Format:** `{"month": "2026-10", "reservations": [{"id": "...", "usd": 40}], "spent_usd": 60, "version": 1}`. A reservation is written before the purchase call and settled after. One still present (a crash or timeout mid-purchase) counts as spent. Amounts are written as exact cents; a fraction of a cent (from a file written by an older release) reads rounded up, and `null`, a quoted or a negative amount makes the file corrupt.
- **Fail closed:** a missing file is a fresh budget. A file that is corrupt, has an unknown version, or cannot be read or locked within 10 seconds refuses every real purchase, with the refusal recorded in the audit log. It is never overwritten.
- **Stale temp files:** a crash between creating the temp file and the rename can leave a `<name>.tmp-*` file next to the state file. It is safe to delete.
- **Reset:** stop every server that uses the file, then delete the file (or edit `spent_usd` and `reservations`) and restart. Do this after confirming whether an unsettled reservation actually bought anything.
Expand Down
2 changes: 1 addition & 1 deletion tools/attempt_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func mustBudgetSpent(t *testing.T, path string) float64 {
t.Helper()
st, err := newFileBudget(path).load()
require.NoError(t, err)
return st.totalUSD()
return float64(st.totalUSD()) / 100
}

type recordingProviderOpts struct {
Expand Down
32 changes: 25 additions & 7 deletions tools/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import (
// clients, and cannot see purchases made elsewhere.
type budgetLedger struct {
mu sync.Mutex
committed float64 // USD of purchases that were, or may have been, made
reserved float64 // USD of purchases in flight
committed usdCents // purchases that were, or may have been, made
reserved usdCents // purchases in flight
}

var processBudget = &budgetLedger{}
Expand Down Expand Up @@ -49,17 +49,35 @@ type budgetReservation struct {
ledger *budgetLedger // in-memory mode
file *fileBudget // persisted mode (EnvBudgetState)
id string // file mode: reservation ID in the state file
usd float64
committed float64 // USD settled before this reservation, for logging
usd usdCents
committed usdCents // settled before this reservation, for logging
}

// budgetCents converts a purchase's USD total and the budget limit for the
// ledgers (spend rounds up, the limit down; see spendCents).
func budgetCents(usd, limit float64) (spend, lim usdCents, err error) {
spend, err = spendCents(usd)
if err != nil {
return 0, 0, fmt.Errorf("refusing real purchase: purchase total: %w", err)
}
lim, err = limitCents(limit)
if err != nil {
return 0, 0, fmt.Errorf("refusing real purchase: %s: %w", EnvBudgetUSD, err)
}
return spend, lim, nil
}

// reserve refuses when committed+reserved+usd would exceed limit (equal is
// allowed) and otherwise holds usd until settle.
func (l *budgetLedger) reserve(usd, limit float64) (*budgetReservation, error) {
func (l *budgetLedger) reserve(usdF, limitF float64) (*budgetReservation, error) {
usd, limit, err := budgetCents(usdF, limitF)
if err != nil {
return nil, err
}
l.mu.Lock()
defer l.mu.Unlock()
if l.committed+l.reserved+usd > limit {
return nil, fmt.Errorf("refusing real purchase: %.2f USD would exceed the cumulative budget %s=%.2f (%.2f USD remaining; the budget is per process and resets on restart)",
return nil, fmt.Errorf("refusing real purchase: %s USD would exceed the cumulative budget %s=%s (%s USD remaining; the budget is per process and resets on restart)",
usd, EnvBudgetUSD, limit, max(0, limit-l.committed-l.reserved))
}
l.reserved += usd
Expand All @@ -83,7 +101,7 @@ func (r *budgetReservation) settle(spent bool) {
func (l *budgetLedger) committedUSD() float64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.committed
return float64(l.committed) / 100
}

// purchaseSpent reports whether a purchase that reached PurchaseCommitment
Expand Down
78 changes: 78 additions & 0 deletions tools/budget_cents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package tools

import (
"fmt"
"math/big"
"strconv"
)

// usdCents is a budget amount in whole US cents. The cumulative budget adds
// and compares cents so float residuals cannot flip a boundary check (#82).
// In JSON it is a USD number ("spent_usd": 60.25), the format older releases
// wrote, so the state file stays readable across versions.
type usdCents int64

// maxBudgetUSD bounds every budget amount. Below it, cents/100 formats to its
// exact decimal, so a state file round-trips without loss.
const maxBudgetUSD = 1e13

const maxBudgetCents = usdCents(maxBudgetUSD * 100)

// spendCents converts a spend amount, rounding up so spend is never
// undercounted. Half-even or half-up rounding could drop up to half a cent.
func spendCents(usd float64) (usdCents, error) {
return toCents(usd, true)
}

// limitCents converts a budget limit, rounding down, so rounding can only
// refuse earlier than exact arithmetic, never admit more.
func limitCents(usd float64) (usdCents, error) {
return toCents(usd, false)
}

// toCents converts exactly, from the shortest decimal that prints usd (0.07 is
// 7 cents, although 0.07*100 is 7.000000000000001 in float64). NaN and ±Inf
// print as words that do not parse.
func toCents(usd float64, roundUp bool) (usdCents, error) {
r, ok := new(big.Rat).SetString(strconv.FormatFloat(usd, 'g', -1, 64))
if !ok {
return 0, fmt.Errorf("budget amount %v is not a finite number", usd)
}
return ratCents(r, roundUp)
}

func ratCents(r *big.Rat, roundUp bool) (usdCents, error) {
if r.Sign() < 0 || r.Cmp(new(big.Rat).SetFloat64(maxBudgetUSD)) > 0 {
return 0, fmt.Errorf("budget amount %s USD is outside 0 to %.0f", r.FloatString(2), float64(maxBudgetUSD))
}
c := new(big.Rat).Mul(r, big.NewRat(100, 1))
q, m := new(big.Int).QuoRem(c.Num(), c.Denom(), new(big.Int))
if roundUp && m.Sign() > 0 {
q.Add(q, big.NewInt(1))
}
return usdCents(q.Int64()), nil
}

// String formats the amount as USD with two decimals.
func (c usdCents) String() string { return fmt.Sprintf("%d.%02d", c/100, c%100) }

func (c usdCents) MarshalJSON() ([]byte, error) {
return []byte(strconv.FormatFloat(float64(c)/100, 'f', -1, 64)), nil
}

// UnmarshalJSON reads the number literal exactly and rounds it up to cents, so
// a non-cent amount in a file written before #82 is never undercounted. null,
// a quoted number or a negative or out-of-range amount is an error (encoding/json
// passes the raw token, and none of those parse as a big.Rat in range).
func (c *usdCents) UnmarshalJSON(b []byte) error {
r, ok := new(big.Rat).SetString(string(b))
if !ok {
return fmt.Errorf("budget amount %s is not a number", b)
}
v, err := ratCents(r, true)
if err != nil {
return err
}
*c = v
return nil
}
Loading
Loading