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

### Added

- `CUDLY_MCP_BUDGET_USD`: a cumulative per-process USD purchase budget. It
is in memory (resets on restart, not shared, blind to purchases made
elsewhere); unknown outcomes count as spent.
- `cudly_archera_comparison`: a read-only comparison of an Archera commitment
plan (current offer and alternatives per line item, plus plan-wide
hypotheticals), with exact decimal money, unknown as null, and both Archera
Expand Down Expand Up @@ -56,6 +59,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Breaking

- **`CUDLY_MCP_BUDGET_USD` is now required for every real purchase.**
Unset, empty or invalid refuses the purchase before credentials are
resolved. The MCPB bundle has a matching setting that must be filled in.
- **`CUDLY_MCP_MAX_COMMITMENT_USD` is now required for every real purchase.**
Unset, empty or invalid refuses the purchase before credentials are
resolved. The MCPB bundle has a matching setting that must be filled in before any real purchase. Executing a
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_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. **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; a cumulative budget is tracked in [#51](https://github.com/LeanerCloud/cloud-commitments-mcp/issues/51). 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**. The budget is held in memory, so it resets when the server restarts, is not shared across processes or MCP clients, and cannot see purchases made in the cloud console, CLI or web (a persisted budget is not implemented). A purchase whose outcome is unknown, or that fails after the provider was called, counts as spent; only a clean no-op is released. 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 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
1 change: 1 addition & 0 deletions cmd/cudly-mcp/gcp_purchase_protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ func TestGCPComputeEngineCUDPurchaseRefusedByUSDCap(t *testing.T) {
t.Setenv(tools.EnvMaxHourlyCommitment, "1000")
t.Setenv(tools.EnvMaxMemoryGB, "1000")
t.Setenv(tools.EnvMaxCommitmentUSD, "1000000000")
t.Setenv(tools.EnvBudgetUSD, "1000000000000")
service := &fakeComputeEngineCommitmentsService{}

provider.GetRegistry().Unregister("gcp")
Expand Down
1 change: 1 addition & 0 deletions cmd/cudly-mcp/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ func TestRealPurchasePastProviderRegistration(t *testing.T) {
t.Setenv(tools.EnvMaxHourlyCommitment, "1000")
t.Setenv(tools.EnvMaxMemoryGB, "1000")
t.Setenv(tools.EnvMaxCommitmentUSD, "1000000000")
t.Setenv(tools.EnvBudgetUSD, "1000000000000")

server, err := cudlymcp.NewServer("test-regression")
require.NoError(t, err)
Expand Down
10 changes: 10 additions & 0 deletions mcpb/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"CUDLY_MCP_MAX_HOURLY_COMMITMENT": "${user_config.max_hourly_commitment}",
"CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}",
"CUDLY_MCP_MAX_COMMITMENT_USD": "${user_config.max_commitment_usd}",
"CUDLY_MCP_BUDGET_USD": "${user_config.budget_usd}",
"ARCHERA_API_KEY": "${user_config.archera_api_key}",
"ARCHERA_ORG_ID": "${user_config.archera_org_id}",
"ARCHERA_PLAN_ID": "${user_config.archera_plan_id}"
Expand All @@ -44,6 +45,7 @@
"CUDLY_MCP_MAX_HOURLY_COMMITMENT": "${user_config.max_hourly_commitment}",
"CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}",
"CUDLY_MCP_MAX_COMMITMENT_USD": "${user_config.max_commitment_usd}",
"CUDLY_MCP_BUDGET_USD": "${user_config.budget_usd}",
"ARCHERA_API_KEY": "${user_config.archera_api_key}",
"ARCHERA_ORG_ID": "${user_config.archera_org_id}",
"ARCHERA_PLAN_ID": "${user_config.archera_plan_id}"
Expand All @@ -58,6 +60,7 @@
"CUDLY_MCP_MAX_HOURLY_COMMITMENT": "${user_config.max_hourly_commitment}",
"CUDLY_MCP_MAX_MEMORY_GB": "${user_config.max_memory_gb}",
"CUDLY_MCP_MAX_COMMITMENT_USD": "${user_config.max_commitment_usd}",
"CUDLY_MCP_BUDGET_USD": "${user_config.budget_usd}",
"ARCHERA_API_KEY": "${user_config.archera_api_key}",
"ARCHERA_ORG_ID": "${user_config.archera_org_id}",
"ARCHERA_PLAN_ID": "${user_config.archera_plan_id}"
Expand Down Expand Up @@ -102,6 +105,13 @@
"min": 1,
"required": false
},
"budget_usd": {
"type": "number",
"title": "Cumulative purchase budget per server process (USD)",
"description": "Total USD that real purchases may commit over their full terms in one server process. Required before any real purchase: leave it empty and purchases are refused. Held in memory: it resets when the server restarts, is not shared across processes or MCP clients, and cannot see purchases made in the cloud console, CLI or elsewhere. A purchase with an unknown outcome counts as spent.",
"min": 1,
"required": false
},
"archera_api_key": {
"type": "string",
"title": "Archera API key",
Expand Down
7 changes: 7 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@
"isSecret": false,
"format": "string"
},
{
"name": "CUDLY_MCP_BUDGET_USD",
"description": "Positive number (USD). Cumulative budget for the total USD all real purchases commit over their full terms in this server process. Required for any real purchase: unset, empty or invalid refuses it. In memory only: resets on restart, not shared across processes or clients, blind to purchases made elsewhere. A purchase with an unknown outcome counts as spent.",
"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.",
Expand Down
65 changes: 65 additions & 0 deletions tools/budget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package tools

import (
"fmt"
"sync"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
)

// budgetLedger is the per-process cumulative purchase budget. It lives in
// memory only: it resets on restart, is not shared across processes or MCP
// 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
}

var processBudget = &budgetLedger{}

// budgetReservation holds part of the budget while a purchase is in flight.
type budgetReservation struct {
ledger *budgetLedger
usd float64
}

// 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) {
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)",
usd, EnvBudgetUSD, limit, max(0, limit-l.committed-l.reserved))
}
l.reserved += usd
return &budgetReservation{ledger: l, usd: usd}, nil
}

// settle moves the reservation into committed when spent, otherwise releases it.
func (r *budgetReservation) settle(spent bool) {
r.ledger.mu.Lock()
defer r.ledger.mu.Unlock()
r.ledger.reserved -= r.usd
if spent {
r.ledger.committed += r.usd
}
}

func (l *budgetLedger) committedUSD() float64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.committed
}

// purchaseSpent reports whether a purchase that reached PurchaseCommitment
// must stay counted. Only a clean no-op (nil error, no success, no error) or
// an adopted existing commitment is released; every error and any other
// result may have spent money, so it stays counted.
func purchaseSpent(result common.PurchaseResult, err error) bool {
if result.ExistingCommitment {
return false
}
return err != nil || result.Success || result.Error != nil
}
146 changes: 146 additions & 0 deletions tools/budget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package tools

import (
"context"
"sync"
"sync/atomic"
"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"
)

// resetBudget gives the test a fresh process ledger and a budget limit.
func resetBudget(t *testing.T, limit string) {
t.Helper()
t.Setenv(EnvBudgetUSD, limit)
t.Setenv(EnvMaxCommitmentUSD, "1000000")
processBudget = &budgetLedger{}
t.Cleanup(func() { processBudget = &budgetLedger{} })
}

// lockedClient serializes the non-thread-safe fake for concurrent tests.
type lockedClient struct {
*fakeServiceClient
mu *sync.Mutex
}

func (c lockedClient) GetOfferingDetails(ctx context.Context, rec common.Recommendation) (*common.OfferingDetails, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.fakeServiceClient.GetOfferingDetails(ctx, rec)
}

func (c lockedClient) PurchaseCommitment(ctx context.Context, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.fakeServiceClient.PurchaseCommitment(ctx, rec, opts)
}

func pricedFake(usd float64, result common.PurchaseResult, err error) *fakeServiceClient {
return &fakeServiceClient{
purchaseResult: result,
purchaseErr: err,
offering: &common.OfferingDetails{TotalCost: usd, Currency: "USD"},
}
}

func runWithNonce(fake provider.ServiceClient, nonce string) error {
rec := testRecommendation()
rec.Count = 1
_, err := ExecutePurchase(context.Background(), PurchaseRequest{
Region: rec.Region, Recommendation: rec, Confirm: true, CredentialScope: "scope", Nonce: nonce,
ResolveClient: func(context.Context) (provider.ServiceClient, error) { return fake, nil },
})
return err
}

func TestBudgetRepeatCallWithFreshNonceRefused(t *testing.T) {
resetBudget(t, "100")
fake := pricedFake(60, common.PurchaseResult{Success: true, CommitmentID: "id"}, nil)
require.NoError(t, runWithNonce(fake, "a"))
err := runWithNonce(fake, "b")
require.ErrorContains(t, err, EnvBudgetUSD)
require.ErrorContains(t, err, "resets on restart")
assert.Equal(t, 1, fake.purchaseCalls)
}

func TestBudgetConcurrentReservations(t *testing.T) {
resetBudget(t, "100")
fake := pricedFake(30, common.PurchaseResult{Success: true, CommitmentID: "id"}, nil)
client := lockedClient{fake, &sync.Mutex{}}
var ok atomic.Int32
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
if runWithNonce(client, "") == nil {
ok.Add(1)
}
}()
}
wg.Wait()
assert.EqualValues(t, 3, ok.Load())
assert.Equal(t, 3, fake.purchaseCalls)
}

func TestBudgetFailedPurchaseKeepsCounted(t *testing.T) {
resetBudget(t, "100")
fake := pricedFake(60, common.PurchaseResult{}, assert.AnError)
require.Error(t, runWithNonce(fake, "a"))
assert.InDelta(t, 60, processBudget.committedUSD(), 1e-9)
require.ErrorContains(t, runWithNonce(fake, "b"), EnvBudgetUSD)
assert.Equal(t, 1, fake.purchaseCalls)
}

func TestBudgetCleanNoopReleases(t *testing.T) {
resetBudget(t, "100")
fake := pricedFake(60, common.PurchaseResult{}, nil)
_ = runWithNonce(fake, "a")
assert.Zero(t, processBudget.committedUSD())
_ = runWithNonce(fake, "b")
assert.Equal(t, 2, fake.purchaseCalls)
}

func TestBudgetExistingCommitmentReleases(t *testing.T) {
resetBudget(t, "100")
fake := pricedFake(60, common.PurchaseResult{Success: true, CommitmentID: "id", ExistingCommitment: true}, nil)
require.NoError(t, runWithNonce(fake, "a"))
assert.Zero(t, processBudget.committedUSD())
require.NoError(t, runWithNonce(fake, "a"))
}

func TestBudgetUnsetRefusesBeforeResolve(t *testing.T) {
resetBudget(t, "")
fake := &fakeServiceClient{}
resolved, err := runRealWith(fake, testRecommendation())
require.ErrorContains(t, err, EnvBudgetUSD)
assert.False(t, resolved)
}

func TestBudgetEnvParsing(t *testing.T) {
for _, v := range []string{"", " ", "abc", "NaN", "Inf", "-1", "0"} {
t.Setenv(EnvBudgetUSD, v)
_, err := readBudget()
require.ErrorContains(t, err, EnvBudgetUSD, "value %q", v)
}
t.Setenv(EnvBudgetUSD, "1e3")
got, err := readBudget()
require.NoError(t, err)
assert.InDelta(t, 1000, got, 1e-9)
}

func TestBudgetEqualToLimitAllowed(t *testing.T) {
resetBudget(t, "100")
fake := pricedFake(60, common.PurchaseResult{Success: true, CommitmentID: "id"}, nil)
require.NoError(t, runWithNonce(fake, "a"))
fake.offering.TotalCost = 40
require.NoError(t, runWithNonce(fake, "b"))
fake.offering.TotalCost = 0.01
require.ErrorContains(t, runWithNonce(fake, "c"), EnvBudgetUSD)
assert.Equal(t, 2, fake.purchaseCalls)
}
Loading
Loading