From f2636eab59c1d013a2ffb6bc97399b162ae67b42 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 00:25:00 +0200 Subject: [PATCH 01/83] docs(mcp): add architecture blueprint for CUDly MCP server Design pass for the MCP server that exposes CUDly's RI/SP/CUD purchase surface to Claude. Documents the CLI surface map, Go-SDK-direct approach (no shell-out), per-(provider,product,action) tool schemas, credential exposure, safety rails (dry-run default, confirm gate, source enum, idempotency), file layout, and a PR-0..PR-9 implementation sequence. Refs #1488 --- docs/design/mcp-server.md | 159 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/design/mcp-server.md diff --git a/docs/design/mcp-server.md b/docs/design/mcp-server.md new file mode 100644 index 000000000..8493c607b --- /dev/null +++ b/docs/design/mcp-server.md @@ -0,0 +1,159 @@ +# CUDly MCP Server - Architecture Blueprint + +> Design pass for [#1488](https://github.com/LeanerCloud/CUDly/issues/1488). No implementation in this PR - this is the plan/architecture document that the implementation PRs (PR-0 through PR-9 in §9) build against. + +## 0. Scope-correcting finding (read this before the rest) + +The premise "CUDly's RI/SP purchase CLI, across AWS, Azure, GCP" does not match the code as checked out. Two separate things exist: + +- **`cmd/main.go`** (`ri-helper`, cobra, single command, no subcommands) is **AWS-only**. `createServiceClient` (`cmd/main.go:301-320`) switches over `common.ServiceType` and only ever instantiates AWS clients (`ec2`, `elasticache`, `memorydb`, `opensearch`, `rds`, `redshift`, `savingsplans`). There is no `azure`/`gcp` branch anywhere in `cmd/`. +- **Azure and GCP commitment-purchase code exists** (`providers/azure/services/{compute,cache,database,search,cosmosdb}/client.go`, `providers/gcp/services/{computeengine,cloudsql,cloudstorage,memorystore}/client.go`, all with a `PurchaseCommitment` method), but it is only reachable today through `internal/api` (the Lambda-based web backend), not through any CLI entrypoint. + +Second, more serious finding: `git status` shows almost everything interesting (`internal/`, `cmd/multi_service_*.go`, `providers/azure/services/*`, `providers/gcp/services/*`) as **untracked** - this working tree is mid-refactor and not fully committed. Concretely, `pkg/provider/interface.go:48` declares `PurchaseCommitment(ctx, rec, opts common.PurchaseOptions)`, and it is implemented with that 3-arg, opts-aware signature only by AWS `ec2` (`client.go:107`), `redshift` (`client.go:141`), `memorydb` (`client.go:111`), `opensearch` (`client.go:139`), `savingsplans` (`client.go:169`). AWS `rds` (`client.go:115`) and `elasticache` (`client.go:112`), and **every** Azure/GCP purchase client read (Azure `compute/client.go:209`, GCP `computeengine/client.go:295`, etc.), implement the older **2-arg** `PurchaseCommitment(ctx, rec)` with no `opts` parameter - no `Source`, no `IdempotencyToken`. This was a static-read discrepancy, not a confirmed compile failure - **verify with a build before scoping any Azure/GCP/RDS/ElastiCache MCP tool**. + +Practical consequence for this design: only AWS EC2, Redshift, MemoryDB, OpenSearch and Savings Plans currently have a `Source`/idempotency-aware purchase path. Everything else needs a small prerequisite PR (see §9, PR-0) before it can safely be an MCP "real purchase" tool. Also worth noting: the Azure VM reservation ID (`compute/client.go:217`, `fmt.Sprintf("vm-reservation-%d", time.Now().Unix())`) and GCP CUD name (`computeengine/client.go:325`) are timestamp-derived, not idempotency-tokened - a retried call after a network timeout can create a second real commitment. Pre-existing bug, out of scope here, flagged per CLAUDE.md. + +## 1. CLI surface map + +`ri-helper` has **no subcommands** - one root command, service selection via `--services`/`--all-services`, purchase toggled via `--purchase`. + +| Flag | Purpose | Required? | Allowed values | Money-affecting | Cite | +|---|---|---|---|---|---| +| `--services, -s` | which resource types to act on | no (default `rds`) | `rds,elasticache,ec2,opensearch,elasticsearch,redshift,memorydb,savingsplans,sp` | yes (selects what gets bought) | `cmd/main.go:86,262-274` | +| `--all-services` | process all 7 supported services | no | bool | yes | `cmd/main.go:87,288-298` | +| `--coverage, -c` | % of recs to buy | no (80.0) | 0-100 | yes | `cmd/main.go:88,122-124` | +| `--purchase` | dry-run (false) vs real purchase (true) | no | bool | **yes - the master switch** | `cmd/main.go:89` | +| `--payment, -p` | payment option | no (`no-upfront`) | `all-upfront, partial-upfront, no-upfront` (bare strings, not a typed enum - `cmd/main.go:147-154`) | yes | `cmd/main.go:92,147-154` | +| `--term, -t` | term length | no (3) | `1` or `3` (int, validated `cmd/main.go:157-159`) | yes | `cmd/main.go:93` | +| `--profile` | AWS named profile | no | string, `~/.aws/config` names | no | `cmd/main.go:94` | +| `--yes` | skip stdin confirmation | no | bool | **do not ever pass this** (memory `feedback_no_yes_flag`) | `cmd/main.go:105` | +| `--max-instances`, `--override-count` | hard caps | no | int, capped at `MaxReasonableInstances=10000` | yes | `cmd/main.go:32,106-107` | +| `--include/exclude-{regions,instance-types,engines,accounts}` | filters | no | free strings, cross-checked for conflicts | no | `cmd/main.go:97-104` | +| `--include/exclude-sp-types` | SP filter | no | `Compute, EC2Instance, SageMaker, Database` | yes (scope) | `cmd/main.go:112-113` | +| `--input-csv`, `-o/--output` | I/O paths | no | `.csv` path | no | `cmd/main.go:90-91` | + +No idempotency-key flag exists on the CLI. Idempotency is provider-side: AWS EC2 RIs are checked-then-tagged with `common.IdempotencyTagKey` (`pkg/common/types.go:266`); Savings Plans use the native `ClientToken` (`providers/aws/services/savingsplans/client.go:204`); the CLI path always leaves `PurchaseOptions.IdempotencyToken` empty (`cmd/multi_service_helpers.go:237`, doc comment `pkg/common/types.go:284-288`). `--purchase` blocks on a **stdin** confirmation prompt unless `--yes` is set (`cmd/helpers.go:160-176`, `bufio.NewReader(os.Stdin)`). + +Azure/GCP: no CLI surface. Their `PurchaseCommitment` methods are the only purchase entrypoint, callable only via Go code (`internal/api` today). + +## 2. SDK choice: **Go, calling internal packages directly - do not shell out** + +Two hard reasons rule out wrapping the built `ri-helper` binary: + +1. **It would deadlock.** `--purchase` without `--yes` blocks on `os.Stdin` (`cmd/helpers.go:168-169`). An MCP subprocess has no interactive stdin. Passing `--yes` to unblock it is explicitly forbidden by the project's own safety rule (`feedback_no_yes_flag`). +2. **It can't reach Azure/GCP.** The CLI's dispatch table is AWS-only (§0/§1). + +Instead, call `pkg/provider.CreateProvider(name, cfg)` (`pkg/provider/factory.go:12`) then `Provider.GetServiceClient(ctx, service, region)` then `ServiceClient.PurchaseCommitment(ctx, rec, opts)` directly, in-process, in Go. This is exactly what `cmd/multi_service_helpers.go:231-247` already does - the MCP server becomes a second caller of the same internal API the CLI uses, with its own confirmation gate (the tool's `confirm`/`dry_run` params) replacing the stdin prompt. Recommend **`github.com/modelcontextprotocol/go-sdk`** (the official Go SDK) over `mark3labs/mcp-go`: it's the spec owner's reference implementation, keeps typed JSON Schema generation from Go structs (fits the "no bare strings" convention already enforced in this repo), and avoids a second Go MCP dependency tree competing with any future first-party tooling. Python/TypeScript would require re-implementing the provider/credential/idempotency logic outside Go, duplicating logic the memory garden explicitly warns against (`feedback_no_hardcoded_magic_values`, `feedback_sdk_enum_string_literals`). + +## 3. Tool surface + +One tool per (provider, product, action). Naming: `cudly___` (snake_case, provider-first so search/autocomplete groups by cloud). Every tool's params require `dry_run: bool` (default `true`) and `confirm: bool` (default `false`); real purchases require `dry_run=false AND confirm=true`. `source` is **not** a free string param - the server injects a fixed enum member (see §7); exposing it as freeform would violate `common.NormalizeSource` (`pkg/common/types.go:301-311`, allowlist of exactly `cudly-cli`/`cudly-web`). + +Phase-1 tool (only one that's safe to ship today per §0): + +```text +name: cudly_aws_ec2_ri_purchase +description: "Purchase AWS EC2 Reserved Instances from a Cost Explorer recommendation. + THIS SPENDS REAL MONEY when dry_run=false and confirm=true. Always run with + dry_run=true first to preview cost and instance count before committing." +params (JSON Schema): + region: string (required, e.g. "us-east-1") + instance_type: string (required, e.g. "m5.large") + count: integer (required, >0) + term_years: integer (required, enum: [1, 3]) + payment_option: string (required, enum: ["all-upfront","partial-upfront","no-upfront"]) + dry_run: boolean (default: true) + confirm: boolean (default: false) +returns: + { success: bool, dry_run: bool, commitment_id: string, cost: number, + on_demand_cost: number, estimated_savings: number, savings_percentage: number, + effective_date: string (RFC3339), term_years: int, error: string|null } +``` + +Same shape repeats for `cudly_aws_savingsplans_purchase` (adds `sp_type` enum `Compute|EC2Instance|SageMaker|Database` per `--include-sp-types`, `cmd/main.go:112`), `cudly_aws_rds_ri_purchase`, `cudly_aws_elasticache_ri_purchase`, `cudly_aws_opensearch_ri_purchase`, `cudly_aws_redshift_ri_purchase`, `cudly_aws_memorydb_ri_purchase` - each **blocked until PR-0** (§9) adds `opts` support to `rds`/`elasticache`. Azure/GCP tools (`cudly_azure_compute_ri_purchase`, `cudly_gcp_computeengine_cud_purchase`, …) are blocked the same way, plus need the retry-safety fix noted in §0. + +Meta-tools: + +- `cudly_list_commitment_actions` - returns the live tool catalog (name, provider, product, whether real-purchase is currently enabled) plus 2-3 example prompts per tool, generated from a single source-of-truth registry in code (§6), never hand-duplicated in docs. +- `cudly_search_recommendations` - wraps the existing `ServiceClient.GetRecommendations` / `RecommendationsClient.GetAllRecommendations` (`pkg/provider/interface.go:44,65`), the same call `cmd/multi_service.go` Phase 1 makes before purchasing. Read-only, no `confirm`/`dry_run` needed. + +## 4. Config exposure + +Env-vars-first, matching each provider's existing ambient-credential model - no new CUDly-specific credential file: + +- **AWS**: `AWS_PROFILE` / `AWS_ACCESS_KEY_ID`+`AWS_SECRET_ACCESS_KEY` / IAM role, same as `--profile` (`cmd/main.go:94`). MCP tool params carry an optional `aws_profile` override per call, mapped to `provider.ProviderConfig.AWSProfile` (`pkg/provider/interface.go:87`). +- **Azure**: `AZURE_CLIENT_ID/SECRET/TENANT_ID` (service principal) or `az login` state, via `azidentity.NewDefaultAzureCredential` (`providers/azure/provider.go:83,149`); `AZURE_SUBSCRIPTION_ID` env var selects the subscription (`providers/azure/provider.go:234`), with a per-call `azure_subscription_id` param overriding it via `ProviderConfig.AzureSubscriptionID`. +- **GCP**: `GOOGLE_APPLICATION_CREDENTIALS` (service-account JSON) or ADC (`providers/gcp/provider.go:220-230`); per-call `gcp_project_id` param overrides via `ProviderConfig.GCPProjectID`. + +The MCP server process itself takes zero CUDly-specific config beyond the provider list to register (`~/.claude/mcp.json` env block); every per-user override happens through per-tool-call params, so one running server instance serves any AWS profile/Azure subscription/GCP project the caller names in the request - it never has to be restarted to switch accounts. + +## 5. Discoverability + +- Naming convention `cudly___` sorts and greps predictably. +- Every description leads with the money-impact sentence and the dry-run recommendation (mandatory template, enforced in code review, not just this doc - see §6). +- `cudly_list_commitment_actions` is the anchor: a session that doesn't know the tool names starts there and gets example prompts ("buy 3-year no-upfront RIs for db.r6g.large in us-east-1"). +- `cudly_search_recommendations` is the natural precursor tool - its output's `SourceRecommendation`/resource fields map 1:1 onto the purchase tools' required params, so a session naturally chains search then purchase. + +## 6. Documentation plan + +`mcp/README.md`: Install (`go install` or prebuilt binary) then Configure credentials (one subsection per provider, mirroring §4) then Launch (`cudly-mcp serve`) then Register in `~/.claude/mcp.json` (example block) then Worked example: `cudly_search_recommendations` then `cudly_aws_ec2_ri_purchase(dry_run=true)` then review output then re-run with `confirm=true, dry_run=false` then Troubleshooting (stuck-pending Azure/GCP polling, credential errors, rate limits). + +Source of truth for per-tool docs lives in code: each tool's Go struct carries its `description` and JSON Schema field docs as struct tags/const strings next to the handler function, and `cudly_list_commitment_actions` + the README's tool table are both generated from that same registry (a `go generate` step), so the two can't drift. + +## 7. Safety rails + +- `dry_run` defaults `true`; server-side gate: refuse any provider call when `!(confirm && !dry_run && source != "")` - return a structured error, not a silent no-op. +- `source` is never user-supplied free text. The server hardcodes a new enum member, e.g. `PurchaseSourceMCP = "cudly-mcp"`, added to the allowlist in `pkg/common/types.go:251-254`/`NormalizeSource` (a one-line, reviewable addition - flagged as a required prerequisite change, not something to route around with a raw string). +- Never pass `--yes` - moot once we stop shelling out to the binary (§2), but the rule still applies to any test harness that does invoke the CLI. +- Missing/invalid money-affecting fields (term, payment option, region, count) return an explicit JSON-RPC error; no defaulting, per `feedback_no_silent_fallbacks`/`feedback_no_hardcoded_magic_values`. +- Every tool call's underlying SDK/HTTP failure surfaces full provider error text + a stable error code back to Claude - never swallowed. +- Rate-limited retry wraps only the outbound `PurchaseCommitment`/SDK call (per `feedback_semaphore_at_api_call`), not tool-param validation or the confirm gate. +- Each real-purchase tool call is idempotency-tokened: the server derives a token from a caller-supplied or server-generated per-call key via `common.DeriveIdempotencyToken` (`pkg/common/tokens.go:41`) and threads it as `PurchaseOptions.IdempotencyToken` - for the 5 already-opts-aware AWS clients today; blocked elsewhere until PR-0. + +## 8. File layout + +```text +mcp/ + server.go # entrypoint, registers tools, wires provider registry + registry.go # single source-of-truth tool catalog (name, schema, desc) + tools/ + aws_ec2_ri.go + aws_savingsplans.go + aws_rds_ri.go # gated behind PR-0 + azure_compute_ri.go # gated behind PR-0 + retry-safety fix + gcp_computeengine_cud.go + search_recommendations.go + list_commitment_actions.go + README.md +cmd/ + cudly-mcp/main.go # thin `main` wiring mcp/server.go - kept out of the + # existing `ri-helper` main.go, never registered as + # a lambda handler +``` + +Keep `mcp/` and `cmd/cudly-mcp/` out of `iac/`, `terraform/`, and any Lambda packaging path (`internal/api` build target) - it's a local/desktop MCP server, not a deployed artifact. + +## 9. Implementation sequence + +- **PR-0 (prerequisite, blocking, not glamorous)**: Confirm with `go build ./...` whether `rds`/`elasticache`/Azure/GCP clients actually satisfy `provider.ServiceClient` as declared. If not, add the `opts common.PurchaseOptions` parameter to their `PurchaseCommitment` signatures and thread `Source`/tagging through, matching the EC2/Redshift pattern. This unblocks every non-AWS-EC2-ish tool below. +- **PR-1**: `mcp/` skeleton + `cudly_list_commitment_actions` (no real tools yet) - proves the transport and registration work. +- **PR-2**: `cudly_search_recommendations` (read-only, no money risk) - proves provider auth/config wiring (§4). +- **PR-3**: `cudly_aws_ec2_ri_purchase`, dry-run only enforced in tests, one test asserting `confirm=false` refuses execution. Gates on PR-1/2. +- **PR-4**: add `common.PurchaseSourceMCP` enum member + idempotency-token wiring (§7). Gates on PR-3. +- **PR-5**: `cudly_aws_savingsplans_purchase` (adds SP-type enum). Gates on PR-4. +- **PR-6**: remaining AWS tools (`rds`, `elasticache`, `opensearch`, `redshift`, `memorydb`). Gates on PR-0. +- **PR-7**: Azure tools, after fixing the timestamp-based reservation-ID retry-safety gap (§0). Gates on PR-0. +- **PR-8**: GCP tools, after fixing the `GENERAL_PURPOSE`/`ResourceCommitment.Type` raw-string-literal issue flagged in §0 (same bug class as the already-fixed `MEMORY_MB` incident per memory). Gates on PR-0. +- **PR-9**: `mcp/README.md` + doc-generation wiring (§6). + +## 10. Open questions for the user + +1. Should PR-0 open first as its own reviewable change (confirming/fixing the `ServiceClient` interface mismatch), before any MCP code lands? Recommended yes - it's a real correctness gap independent of MCP. +2. `source`: confirm the design choice to hardcode a new `cudly-mcp` enum value server-side rather than exposing `source` as a tool param at all (assumed the latter based on `NormalizeSource`'s allowlist). +3. Do you want Azure/GCP real-purchase tools gated behind the retry-safety fixes in §0, or shipped dry-run-only until those land? +4. Should `mcp/` live in this monorepo (as designed) or as a separate repo consuming CUDly's Go modules - affects whether it's covered by this repo's CI/pre-commit gates? +5. Given the untracked working-tree state (§0), does `main` (the actual git history) not yet contain `internal/`, `cmd/multi_service_*.go`, or the Azure/GCP service clients? If so, PR-0 through PR-9 need to land on top of whatever gets committed first - this changes the PR base significantly. + +--- + +Key files cited: `cmd/main.go`, `cmd/multi_service_helpers.go`, `cmd/helpers.go`, `pkg/provider/interface.go`, `pkg/provider/factory.go`, `pkg/common/types.go`, `pkg/common/tokens.go`, `providers/aws/services/{ec2,rds,elasticache,redshift,memorydb,opensearch,savingsplans}/client.go`, `providers/aws/internal/tagging/purchase_tags.go`, `providers/azure/{provider.go,services/compute/client.go}`, `providers/gcp/{provider.go,services/computeengine/client.go}`. From 16d964fe02a5ea942419d61df9d5bcccf72a0cfb Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 00:56:52 +0200 Subject: [PATCH 02/83] docs(mcp): remove architecture blueprint doc Design work is complete; implementation follows in this same PR. --- docs/design/mcp-server.md | 159 -------------------------------------- 1 file changed, 159 deletions(-) delete mode 100644 docs/design/mcp-server.md diff --git a/docs/design/mcp-server.md b/docs/design/mcp-server.md deleted file mode 100644 index 8493c607b..000000000 --- a/docs/design/mcp-server.md +++ /dev/null @@ -1,159 +0,0 @@ -# CUDly MCP Server - Architecture Blueprint - -> Design pass for [#1488](https://github.com/LeanerCloud/CUDly/issues/1488). No implementation in this PR - this is the plan/architecture document that the implementation PRs (PR-0 through PR-9 in §9) build against. - -## 0. Scope-correcting finding (read this before the rest) - -The premise "CUDly's RI/SP purchase CLI, across AWS, Azure, GCP" does not match the code as checked out. Two separate things exist: - -- **`cmd/main.go`** (`ri-helper`, cobra, single command, no subcommands) is **AWS-only**. `createServiceClient` (`cmd/main.go:301-320`) switches over `common.ServiceType` and only ever instantiates AWS clients (`ec2`, `elasticache`, `memorydb`, `opensearch`, `rds`, `redshift`, `savingsplans`). There is no `azure`/`gcp` branch anywhere in `cmd/`. -- **Azure and GCP commitment-purchase code exists** (`providers/azure/services/{compute,cache,database,search,cosmosdb}/client.go`, `providers/gcp/services/{computeengine,cloudsql,cloudstorage,memorystore}/client.go`, all with a `PurchaseCommitment` method), but it is only reachable today through `internal/api` (the Lambda-based web backend), not through any CLI entrypoint. - -Second, more serious finding: `git status` shows almost everything interesting (`internal/`, `cmd/multi_service_*.go`, `providers/azure/services/*`, `providers/gcp/services/*`) as **untracked** - this working tree is mid-refactor and not fully committed. Concretely, `pkg/provider/interface.go:48` declares `PurchaseCommitment(ctx, rec, opts common.PurchaseOptions)`, and it is implemented with that 3-arg, opts-aware signature only by AWS `ec2` (`client.go:107`), `redshift` (`client.go:141`), `memorydb` (`client.go:111`), `opensearch` (`client.go:139`), `savingsplans` (`client.go:169`). AWS `rds` (`client.go:115`) and `elasticache` (`client.go:112`), and **every** Azure/GCP purchase client read (Azure `compute/client.go:209`, GCP `computeengine/client.go:295`, etc.), implement the older **2-arg** `PurchaseCommitment(ctx, rec)` with no `opts` parameter - no `Source`, no `IdempotencyToken`. This was a static-read discrepancy, not a confirmed compile failure - **verify with a build before scoping any Azure/GCP/RDS/ElastiCache MCP tool**. - -Practical consequence for this design: only AWS EC2, Redshift, MemoryDB, OpenSearch and Savings Plans currently have a `Source`/idempotency-aware purchase path. Everything else needs a small prerequisite PR (see §9, PR-0) before it can safely be an MCP "real purchase" tool. Also worth noting: the Azure VM reservation ID (`compute/client.go:217`, `fmt.Sprintf("vm-reservation-%d", time.Now().Unix())`) and GCP CUD name (`computeengine/client.go:325`) are timestamp-derived, not idempotency-tokened - a retried call after a network timeout can create a second real commitment. Pre-existing bug, out of scope here, flagged per CLAUDE.md. - -## 1. CLI surface map - -`ri-helper` has **no subcommands** - one root command, service selection via `--services`/`--all-services`, purchase toggled via `--purchase`. - -| Flag | Purpose | Required? | Allowed values | Money-affecting | Cite | -|---|---|---|---|---|---| -| `--services, -s` | which resource types to act on | no (default `rds`) | `rds,elasticache,ec2,opensearch,elasticsearch,redshift,memorydb,savingsplans,sp` | yes (selects what gets bought) | `cmd/main.go:86,262-274` | -| `--all-services` | process all 7 supported services | no | bool | yes | `cmd/main.go:87,288-298` | -| `--coverage, -c` | % of recs to buy | no (80.0) | 0-100 | yes | `cmd/main.go:88,122-124` | -| `--purchase` | dry-run (false) vs real purchase (true) | no | bool | **yes - the master switch** | `cmd/main.go:89` | -| `--payment, -p` | payment option | no (`no-upfront`) | `all-upfront, partial-upfront, no-upfront` (bare strings, not a typed enum - `cmd/main.go:147-154`) | yes | `cmd/main.go:92,147-154` | -| `--term, -t` | term length | no (3) | `1` or `3` (int, validated `cmd/main.go:157-159`) | yes | `cmd/main.go:93` | -| `--profile` | AWS named profile | no | string, `~/.aws/config` names | no | `cmd/main.go:94` | -| `--yes` | skip stdin confirmation | no | bool | **do not ever pass this** (memory `feedback_no_yes_flag`) | `cmd/main.go:105` | -| `--max-instances`, `--override-count` | hard caps | no | int, capped at `MaxReasonableInstances=10000` | yes | `cmd/main.go:32,106-107` | -| `--include/exclude-{regions,instance-types,engines,accounts}` | filters | no | free strings, cross-checked for conflicts | no | `cmd/main.go:97-104` | -| `--include/exclude-sp-types` | SP filter | no | `Compute, EC2Instance, SageMaker, Database` | yes (scope) | `cmd/main.go:112-113` | -| `--input-csv`, `-o/--output` | I/O paths | no | `.csv` path | no | `cmd/main.go:90-91` | - -No idempotency-key flag exists on the CLI. Idempotency is provider-side: AWS EC2 RIs are checked-then-tagged with `common.IdempotencyTagKey` (`pkg/common/types.go:266`); Savings Plans use the native `ClientToken` (`providers/aws/services/savingsplans/client.go:204`); the CLI path always leaves `PurchaseOptions.IdempotencyToken` empty (`cmd/multi_service_helpers.go:237`, doc comment `pkg/common/types.go:284-288`). `--purchase` blocks on a **stdin** confirmation prompt unless `--yes` is set (`cmd/helpers.go:160-176`, `bufio.NewReader(os.Stdin)`). - -Azure/GCP: no CLI surface. Their `PurchaseCommitment` methods are the only purchase entrypoint, callable only via Go code (`internal/api` today). - -## 2. SDK choice: **Go, calling internal packages directly - do not shell out** - -Two hard reasons rule out wrapping the built `ri-helper` binary: - -1. **It would deadlock.** `--purchase` without `--yes` blocks on `os.Stdin` (`cmd/helpers.go:168-169`). An MCP subprocess has no interactive stdin. Passing `--yes` to unblock it is explicitly forbidden by the project's own safety rule (`feedback_no_yes_flag`). -2. **It can't reach Azure/GCP.** The CLI's dispatch table is AWS-only (§0/§1). - -Instead, call `pkg/provider.CreateProvider(name, cfg)` (`pkg/provider/factory.go:12`) then `Provider.GetServiceClient(ctx, service, region)` then `ServiceClient.PurchaseCommitment(ctx, rec, opts)` directly, in-process, in Go. This is exactly what `cmd/multi_service_helpers.go:231-247` already does - the MCP server becomes a second caller of the same internal API the CLI uses, with its own confirmation gate (the tool's `confirm`/`dry_run` params) replacing the stdin prompt. Recommend **`github.com/modelcontextprotocol/go-sdk`** (the official Go SDK) over `mark3labs/mcp-go`: it's the spec owner's reference implementation, keeps typed JSON Schema generation from Go structs (fits the "no bare strings" convention already enforced in this repo), and avoids a second Go MCP dependency tree competing with any future first-party tooling. Python/TypeScript would require re-implementing the provider/credential/idempotency logic outside Go, duplicating logic the memory garden explicitly warns against (`feedback_no_hardcoded_magic_values`, `feedback_sdk_enum_string_literals`). - -## 3. Tool surface - -One tool per (provider, product, action). Naming: `cudly___` (snake_case, provider-first so search/autocomplete groups by cloud). Every tool's params require `dry_run: bool` (default `true`) and `confirm: bool` (default `false`); real purchases require `dry_run=false AND confirm=true`. `source` is **not** a free string param - the server injects a fixed enum member (see §7); exposing it as freeform would violate `common.NormalizeSource` (`pkg/common/types.go:301-311`, allowlist of exactly `cudly-cli`/`cudly-web`). - -Phase-1 tool (only one that's safe to ship today per §0): - -```text -name: cudly_aws_ec2_ri_purchase -description: "Purchase AWS EC2 Reserved Instances from a Cost Explorer recommendation. - THIS SPENDS REAL MONEY when dry_run=false and confirm=true. Always run with - dry_run=true first to preview cost and instance count before committing." -params (JSON Schema): - region: string (required, e.g. "us-east-1") - instance_type: string (required, e.g. "m5.large") - count: integer (required, >0) - term_years: integer (required, enum: [1, 3]) - payment_option: string (required, enum: ["all-upfront","partial-upfront","no-upfront"]) - dry_run: boolean (default: true) - confirm: boolean (default: false) -returns: - { success: bool, dry_run: bool, commitment_id: string, cost: number, - on_demand_cost: number, estimated_savings: number, savings_percentage: number, - effective_date: string (RFC3339), term_years: int, error: string|null } -``` - -Same shape repeats for `cudly_aws_savingsplans_purchase` (adds `sp_type` enum `Compute|EC2Instance|SageMaker|Database` per `--include-sp-types`, `cmd/main.go:112`), `cudly_aws_rds_ri_purchase`, `cudly_aws_elasticache_ri_purchase`, `cudly_aws_opensearch_ri_purchase`, `cudly_aws_redshift_ri_purchase`, `cudly_aws_memorydb_ri_purchase` - each **blocked until PR-0** (§9) adds `opts` support to `rds`/`elasticache`. Azure/GCP tools (`cudly_azure_compute_ri_purchase`, `cudly_gcp_computeengine_cud_purchase`, …) are blocked the same way, plus need the retry-safety fix noted in §0. - -Meta-tools: - -- `cudly_list_commitment_actions` - returns the live tool catalog (name, provider, product, whether real-purchase is currently enabled) plus 2-3 example prompts per tool, generated from a single source-of-truth registry in code (§6), never hand-duplicated in docs. -- `cudly_search_recommendations` - wraps the existing `ServiceClient.GetRecommendations` / `RecommendationsClient.GetAllRecommendations` (`pkg/provider/interface.go:44,65`), the same call `cmd/multi_service.go` Phase 1 makes before purchasing. Read-only, no `confirm`/`dry_run` needed. - -## 4. Config exposure - -Env-vars-first, matching each provider's existing ambient-credential model - no new CUDly-specific credential file: - -- **AWS**: `AWS_PROFILE` / `AWS_ACCESS_KEY_ID`+`AWS_SECRET_ACCESS_KEY` / IAM role, same as `--profile` (`cmd/main.go:94`). MCP tool params carry an optional `aws_profile` override per call, mapped to `provider.ProviderConfig.AWSProfile` (`pkg/provider/interface.go:87`). -- **Azure**: `AZURE_CLIENT_ID/SECRET/TENANT_ID` (service principal) or `az login` state, via `azidentity.NewDefaultAzureCredential` (`providers/azure/provider.go:83,149`); `AZURE_SUBSCRIPTION_ID` env var selects the subscription (`providers/azure/provider.go:234`), with a per-call `azure_subscription_id` param overriding it via `ProviderConfig.AzureSubscriptionID`. -- **GCP**: `GOOGLE_APPLICATION_CREDENTIALS` (service-account JSON) or ADC (`providers/gcp/provider.go:220-230`); per-call `gcp_project_id` param overrides via `ProviderConfig.GCPProjectID`. - -The MCP server process itself takes zero CUDly-specific config beyond the provider list to register (`~/.claude/mcp.json` env block); every per-user override happens through per-tool-call params, so one running server instance serves any AWS profile/Azure subscription/GCP project the caller names in the request - it never has to be restarted to switch accounts. - -## 5. Discoverability - -- Naming convention `cudly___` sorts and greps predictably. -- Every description leads with the money-impact sentence and the dry-run recommendation (mandatory template, enforced in code review, not just this doc - see §6). -- `cudly_list_commitment_actions` is the anchor: a session that doesn't know the tool names starts there and gets example prompts ("buy 3-year no-upfront RIs for db.r6g.large in us-east-1"). -- `cudly_search_recommendations` is the natural precursor tool - its output's `SourceRecommendation`/resource fields map 1:1 onto the purchase tools' required params, so a session naturally chains search then purchase. - -## 6. Documentation plan - -`mcp/README.md`: Install (`go install` or prebuilt binary) then Configure credentials (one subsection per provider, mirroring §4) then Launch (`cudly-mcp serve`) then Register in `~/.claude/mcp.json` (example block) then Worked example: `cudly_search_recommendations` then `cudly_aws_ec2_ri_purchase(dry_run=true)` then review output then re-run with `confirm=true, dry_run=false` then Troubleshooting (stuck-pending Azure/GCP polling, credential errors, rate limits). - -Source of truth for per-tool docs lives in code: each tool's Go struct carries its `description` and JSON Schema field docs as struct tags/const strings next to the handler function, and `cudly_list_commitment_actions` + the README's tool table are both generated from that same registry (a `go generate` step), so the two can't drift. - -## 7. Safety rails - -- `dry_run` defaults `true`; server-side gate: refuse any provider call when `!(confirm && !dry_run && source != "")` - return a structured error, not a silent no-op. -- `source` is never user-supplied free text. The server hardcodes a new enum member, e.g. `PurchaseSourceMCP = "cudly-mcp"`, added to the allowlist in `pkg/common/types.go:251-254`/`NormalizeSource` (a one-line, reviewable addition - flagged as a required prerequisite change, not something to route around with a raw string). -- Never pass `--yes` - moot once we stop shelling out to the binary (§2), but the rule still applies to any test harness that does invoke the CLI. -- Missing/invalid money-affecting fields (term, payment option, region, count) return an explicit JSON-RPC error; no defaulting, per `feedback_no_silent_fallbacks`/`feedback_no_hardcoded_magic_values`. -- Every tool call's underlying SDK/HTTP failure surfaces full provider error text + a stable error code back to Claude - never swallowed. -- Rate-limited retry wraps only the outbound `PurchaseCommitment`/SDK call (per `feedback_semaphore_at_api_call`), not tool-param validation or the confirm gate. -- Each real-purchase tool call is idempotency-tokened: the server derives a token from a caller-supplied or server-generated per-call key via `common.DeriveIdempotencyToken` (`pkg/common/tokens.go:41`) and threads it as `PurchaseOptions.IdempotencyToken` - for the 5 already-opts-aware AWS clients today; blocked elsewhere until PR-0. - -## 8. File layout - -```text -mcp/ - server.go # entrypoint, registers tools, wires provider registry - registry.go # single source-of-truth tool catalog (name, schema, desc) - tools/ - aws_ec2_ri.go - aws_savingsplans.go - aws_rds_ri.go # gated behind PR-0 - azure_compute_ri.go # gated behind PR-0 + retry-safety fix - gcp_computeengine_cud.go - search_recommendations.go - list_commitment_actions.go - README.md -cmd/ - cudly-mcp/main.go # thin `main` wiring mcp/server.go - kept out of the - # existing `ri-helper` main.go, never registered as - # a lambda handler -``` - -Keep `mcp/` and `cmd/cudly-mcp/` out of `iac/`, `terraform/`, and any Lambda packaging path (`internal/api` build target) - it's a local/desktop MCP server, not a deployed artifact. - -## 9. Implementation sequence - -- **PR-0 (prerequisite, blocking, not glamorous)**: Confirm with `go build ./...` whether `rds`/`elasticache`/Azure/GCP clients actually satisfy `provider.ServiceClient` as declared. If not, add the `opts common.PurchaseOptions` parameter to their `PurchaseCommitment` signatures and thread `Source`/tagging through, matching the EC2/Redshift pattern. This unblocks every non-AWS-EC2-ish tool below. -- **PR-1**: `mcp/` skeleton + `cudly_list_commitment_actions` (no real tools yet) - proves the transport and registration work. -- **PR-2**: `cudly_search_recommendations` (read-only, no money risk) - proves provider auth/config wiring (§4). -- **PR-3**: `cudly_aws_ec2_ri_purchase`, dry-run only enforced in tests, one test asserting `confirm=false` refuses execution. Gates on PR-1/2. -- **PR-4**: add `common.PurchaseSourceMCP` enum member + idempotency-token wiring (§7). Gates on PR-3. -- **PR-5**: `cudly_aws_savingsplans_purchase` (adds SP-type enum). Gates on PR-4. -- **PR-6**: remaining AWS tools (`rds`, `elasticache`, `opensearch`, `redshift`, `memorydb`). Gates on PR-0. -- **PR-7**: Azure tools, after fixing the timestamp-based reservation-ID retry-safety gap (§0). Gates on PR-0. -- **PR-8**: GCP tools, after fixing the `GENERAL_PURPOSE`/`ResourceCommitment.Type` raw-string-literal issue flagged in §0 (same bug class as the already-fixed `MEMORY_MB` incident per memory). Gates on PR-0. -- **PR-9**: `mcp/README.md` + doc-generation wiring (§6). - -## 10. Open questions for the user - -1. Should PR-0 open first as its own reviewable change (confirming/fixing the `ServiceClient` interface mismatch), before any MCP code lands? Recommended yes - it's a real correctness gap independent of MCP. -2. `source`: confirm the design choice to hardcode a new `cudly-mcp` enum value server-side rather than exposing `source` as a tool param at all (assumed the latter based on `NormalizeSource`'s allowlist). -3. Do you want Azure/GCP real-purchase tools gated behind the retry-safety fixes in §0, or shipped dry-run-only until those land? -4. Should `mcp/` live in this monorepo (as designed) or as a separate repo consuming CUDly's Go modules - affects whether it's covered by this repo's CI/pre-commit gates? -5. Given the untracked working-tree state (§0), does `main` (the actual git history) not yet contain `internal/`, `cmd/multi_service_*.go`, or the Azure/GCP service clients? If so, PR-0 through PR-9 need to land on top of whatever gets committed first - this changes the PR base significantly. - ---- - -Key files cited: `cmd/main.go`, `cmd/multi_service_helpers.go`, `cmd/helpers.go`, `pkg/provider/interface.go`, `pkg/provider/factory.go`, `pkg/common/types.go`, `pkg/common/tokens.go`, `providers/aws/services/{ec2,rds,elasticache,redshift,memorydb,opensearch,savingsplans}/client.go`, `providers/aws/internal/tagging/purchase_tags.go`, `providers/azure/{provider.go,services/compute/client.go}`, `providers/gcp/{provider.go,services/computeengine/client.go}`. From 5a6f8c37673da1a482cc4a6651d52ffff1ebe9b8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 00:58:56 +0200 Subject: [PATCH 03/83] feat(common): add cudly-mcp purchase source enum Adds PurchaseSourceMCP so the upcoming MCP server can stamp purchases it makes with a server-controlled enum value, never a free-form string. NormalizeSource now accepts cudly-mcp alongside cudly-cli and cudly-web and reports it in the invalid-source error message. --- pkg/common/types.go | 5 +++-- pkg/common/types_test.go | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/common/types.go b/pkg/common/types.go index d5507618f..56d4e9b12 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -312,6 +312,7 @@ type PurchaseResult struct { const ( PurchaseSourceCLI = "cudly-cli" PurchaseSourceWeb = "cudly-web" + PurchaseSourceMCP = "cudly-mcp" ) // PurchaseTagKey is the tag/label key every CUDly-purchased commitment carries @@ -368,12 +369,12 @@ type PurchaseOptions struct { func NormalizeSource(s string) (string, error) { lower := strings.ToLower(strings.TrimSpace(s)) switch lower { - case PurchaseSourceCLI, PurchaseSourceWeb: + case PurchaseSourceCLI, PurchaseSourceWeb, PurchaseSourceMCP: return lower, nil case "": return "", fmt.Errorf("purchase source is required") default: - return "", fmt.Errorf("invalid purchase source %q (allowed: %s, %s)", s, PurchaseSourceCLI, PurchaseSourceWeb) + return "", fmt.Errorf("invalid purchase source %q (allowed: %s, %s, %s)", s, PurchaseSourceCLI, PurchaseSourceWeb, PurchaseSourceMCP) } } diff --git a/pkg/common/types_test.go b/pkg/common/types_test.go index 1949a8b61..d613dbe92 100644 --- a/pkg/common/types_test.go +++ b/pkg/common/types_test.go @@ -480,8 +480,10 @@ func TestNormalizeSource(t *testing.T) { }{ {"cli lowercase", "cudly-cli", "cudly-cli", false}, {"web lowercase", "cudly-web", "cudly-web", false}, + {"mcp lowercase", "cudly-mcp", "cudly-mcp", false}, {"cli mixed case", "CUDly-CLI", "cudly-cli", false}, {"web mixed case", "CUDly-Web", "cudly-web", false}, + {"mcp mixed case", "CUDly-MCP", "cudly-mcp", false}, {"cli with whitespace", " cudly-cli\n", "cudly-cli", false}, {"empty string", "", "", true}, {"whitespace only", " ", "", true}, From e3a0f14a19759115e3cb581482c33936ad5f638f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:20:32 +0200 Subject: [PATCH 04/83] feat(mcp): server skeleton + list-commitment-actions tool Adds the CUDly MCP server foundation: a thin cmd/cudly-mcp/main.go entry point, mcp/server.go wiring, and a shared mcp/tools harness (typed enum validators, JSON-schema builder, and the dry_run/confirm purchase gate every purchase tool below will reuse). Registers the first tool, cudly_list_commitment_actions, which returns the live tool catalog built from each tool's own Descriptor() so the catalog can never drift from what is actually registered. SDK choice: github.com/modelcontextprotocol/go-sdk (v1.6.1, the current stable release; pre-release tags exist past it but were skipped). It is the spec owner's reference implementation and its generic AddTool infers JSON Schema from Go structs, which keeps every tool's schema typed rather than hand-built. mark3labs/mcp-go was the documented fallback if this SDK proved awkward; it did not, so no fallback was needed. The purchase gate (mcp/tools/purchase.go) is written and tested now, ahead of the AWS EC2 tool that will be its first real caller, so the safety-rail tests (confirm=false refuses execution; dry_run=true never resolves a provider client; same request derives the same idempotency token) land independently of any one provider's wiring. --- cmd/cudly-mcp/main.go | 35 +++ go.mod | 5 + go.sum | 12 + go.work.sum | 2 + mcp/server.go | 53 +++++ mcp/server_test.go | 60 +++++ mcp/tools/enums.go | 175 ++++++++++++++ mcp/tools/enums_test.go | 205 +++++++++++++++++ mcp/tools/list_commitment_actions.go | 88 ++++++++ mcp/tools/list_commitment_actions_test.go | 48 ++++ mcp/tools/purchase.go | 150 ++++++++++++ mcp/tools/purchase_test.go | 263 ++++++++++++++++++++++ mcp/tools/registry.go | 41 ++++ mcp/tools/schema.go | 57 +++++ mcp/tools/schema_test.go | 58 +++++ 15 files changed, 1252 insertions(+) create mode 100644 cmd/cudly-mcp/main.go create mode 100644 mcp/server.go create mode 100644 mcp/server_test.go create mode 100644 mcp/tools/enums.go create mode 100644 mcp/tools/enums_test.go create mode 100644 mcp/tools/list_commitment_actions.go create mode 100644 mcp/tools/list_commitment_actions_test.go create mode 100644 mcp/tools/purchase.go create mode 100644 mcp/tools/purchase_test.go create mode 100644 mcp/tools/registry.go create mode 100644 mcp/tools/schema.go create mode 100644 mcp/tools/schema_test.go diff --git a/cmd/cudly-mcp/main.go b/cmd/cudly-mcp/main.go new file mode 100644 index 000000000..7817f266f --- /dev/null +++ b/cmd/cudly-mcp/main.go @@ -0,0 +1,35 @@ +// Command cudly-mcp runs the CUDly MCP server on stdio, exposing CUDly's +// RI/SP/CUD search and purchase tools to any MCP client (e.g. Claude Code +// via ~/.claude/mcp.json). See mcp/README.md for setup and usage. +// +// This binary is intentionally separate from the ri-helper CLI (cmd/main.go): +// it is a local/desktop MCP server, never a Lambda handler, and is kept out +// of iac/ and terraform/ (see mcp/README.md "Deployment model"). +package main + +import ( + "context" + "log" + "os" + + gosdk "github.com/modelcontextprotocol/go-sdk/mcp" + + cudlymcp "github.com/LeanerCloud/CUDly/mcp" +) + +// version is overridable at build time via: +// +// go build -ldflags "-X main.version=1.2.3" ./cmd/cudly-mcp +var version = "dev" + +func main() { + server, err := cudlymcp.NewServer(version) + if err != nil { + log.Fatalf("cudly-mcp: failed to build server: %v", err) + } + + if err := server.Run(context.Background(), &gosdk.StdioTransport{}); err != nil { + log.Printf("cudly-mcp: server exited with error: %v", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index cb038572a..1d1e06f6d 100644 --- a/go.mod +++ b/go.mod @@ -104,9 +104,11 @@ require ( github.com/coreos/go-oidc/v3 v3.18.0 github.com/go-jose/go-jose/v4 v4.1.4 github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 github.com/microsoftgraph/msgraph-sdk-go v1.99.0 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/pashagolub/pgxmock/v4 v4.9.0 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 @@ -175,12 +177,15 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect diff --git a/go.sum b/go.sum index 52b72308e..2f9bf49bc 100644 --- a/go.sum +++ b/go.sum @@ -222,6 +222,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -296,6 +298,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -318,6 +322,10 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:Om github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -345,6 +353,8 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -387,6 +397,8 @@ golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= diff --git a/go.work.sum b/go.work.sum index 9890d079d..77b0e5af9 100644 --- a/go.work.sum +++ b/go.work.sum @@ -489,6 +489,7 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -697,6 +698,7 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/mcp/server.go b/mcp/server.go new file mode 100644 index 000000000..942768ef3 --- /dev/null +++ b/mcp/server.go @@ -0,0 +1,53 @@ +// Package mcp wires the CUDly MCP server: it registers every tool from +// mcp/tools onto a github.com/modelcontextprotocol/go-sdk/mcp.Server and +// builds the cudly_list_commitment_actions catalog from those same tools' +// descriptors, so the live tool set and the discoverability catalog can +// never drift apart. +package mcp + +import ( + "fmt" + + gosdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/LeanerCloud/CUDly/mcp/tools" +) + +// ServerName is the MCP Implementation.Name this server identifies as. +const ServerName = "cudly-mcp" + +// registrations returns every purchase/search tool this server exposes, +// excluding cudly_list_commitment_actions itself (NewServer adds that one +// last, once it has every other tool's Descriptor to build the catalog +// from). +func registrations() []tools.Registration { + return []tools.Registration{} +} + +// NewServer builds the CUDly MCP server with every tool registered. version +// is reported to clients as the server's Implementation.Version (pass the +// build-time version string, or "dev" for unreleased builds). Callers run +// the returned server on a transport, e.g.: +// +// server, err := mcp.NewServer("1.0.0") +// server.Run(ctx, &gosdk.StdioTransport{}) +func NewServer(version string) (*gosdk.Server, error) { + s := gosdk.NewServer(&gosdk.Implementation{Name: ServerName, Version: version}, nil) + + regs := registrations() + descriptors := make([]tools.Descriptor, 0, len(regs)+1) + for _, r := range regs { + descriptors = append(descriptors, r.Descriptor()) + } + + listTool := tools.NewListCommitmentActions(descriptors) + regs = append(regs, listTool) + + for _, r := range regs { + if err := r.Register(s); err != nil { + return nil, fmt.Errorf("register tool %q: %w", r.Descriptor().Name, err) + } + } + + return s, nil +} diff --git a/mcp/server_test.go b/mcp/server_test.go new file mode 100644 index 000000000..b535e805c --- /dev/null +++ b/mcp/server_test.go @@ -0,0 +1,60 @@ +package mcp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/mcp/tools" +) + +func TestNewServerBuildsWithoutError(t *testing.T) { + t.Parallel() + s, err := NewServer("test") + require.NoError(t, err) + require.NotNil(t, s) +} + +// TestRegistryNonEmpty proves the tool registry is never accidentally empty: +// cudly_list_commitment_actions is always present, even before any +// purchase/search tool has been registered. +func TestRegistryNonEmpty(t *testing.T) { + t.Parallel() + regs := registrations() + descriptors := make([]tools.Descriptor, 0, len(regs)+1) + for _, r := range regs { + descriptors = append(descriptors, r.Descriptor()) + } + listTool := tools.NewListCommitmentActions(descriptors) + descriptors = append(descriptors, listTool.Descriptor()) + + require.NotEmpty(t, descriptors) + names := make(map[string]bool, len(descriptors)) + for _, d := range descriptors { + names[d.Name] = true + } + assert.True(t, names["cudly_list_commitment_actions"]) +} + +// TestRealPurchaseToolsDocumentMoneyImpactAndDryRun proves every tool that +// can execute a real purchase leads its description with a money-impact +// statement and a dry-run recommendation (design doc §3/§5) -- so any future +// purchase tool that forgets one fails this test rather than shipping a +// tool description that quietly omits the safety framing every other +// purchase tool carries. +func TestRealPurchaseToolsDocumentMoneyImpactAndDryRun(t *testing.T) { + t.Parallel() + for _, r := range registrations() { + d := r.Descriptor() + if !d.RealPurchaseEnabled { + continue + } + lower := strings.ToLower(d.Description) + assert.Truef(t, strings.Contains(lower, "real money") || strings.Contains(lower, "spends money"), + "tool %q description must state its money impact: %q", d.Name, d.Description) + assert.Truef(t, strings.Contains(lower, "dry_run") || strings.Contains(lower, "dry run"), + "tool %q description must recommend dry_run first: %q", d.Name, d.Description) + } +} diff --git a/mcp/tools/enums.go b/mcp/tools/enums.go new file mode 100644 index 000000000..e475aaec8 --- /dev/null +++ b/mcp/tools/enums.go @@ -0,0 +1,175 @@ +// Package tools implements the individual MCP tool handlers exposed by the +// CUDly MCP server (mcp/server.go), plus the shared validation and purchase +// harness they all build on. +package tools + +import ( + "fmt" + + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" +) + +// PaymentOption is the AWS/Azure/GCP-agnostic reserved-capacity payment +// schedule. It is validated at the MCP tool boundary before being copied +// onto common.Recommendation.PaymentOption (which stays a bare string there +// for backward compatibility with existing CSV/DB rows -- see +// pkg/common/types.go) so a caller can never smuggle an unrecognised payment +// term into a purchase. +type PaymentOption string + +const ( + PaymentOptionAllUpfront PaymentOption = "all-upfront" + PaymentOptionPartialUpfront PaymentOption = "partial-upfront" + PaymentOptionNoUpfront PaymentOption = "no-upfront" +) + +// ValidatePaymentOption returns the typed PaymentOption for s, or an explicit +// error when s is not one of the three allowed values. There is no default: +// an empty or unknown payment option is always an error, never silently +// coerced to a fallback term (feedback_no_silent_fallbacks). +func ValidatePaymentOption(s string) (PaymentOption, error) { + switch PaymentOption(s) { + case PaymentOptionAllUpfront, PaymentOptionPartialUpfront, PaymentOptionNoUpfront: + return PaymentOption(s), nil + default: + return "", fmt.Errorf("invalid payment_option %q: must be one of %s, %s, %s", + s, PaymentOptionAllUpfront, PaymentOptionPartialUpfront, PaymentOptionNoUpfront) + } +} + +// TermYears is the reserved-capacity commitment length, in years. +type TermYears int + +const ( + TermOneYear TermYears = 1 + TermThreeYear TermYears = 3 +) + +// ValidateTermYears returns the typed TermYears for n, or an explicit error +// when n is not 1 or 3 (the only terms AWS/Azure/GCP reserved-capacity +// products offer). +func ValidateTermYears(n int) (TermYears, error) { + switch TermYears(n) { + case TermOneYear, TermThreeYear: + return TermYears(n), nil + default: + return 0, fmt.Errorf("invalid term_years %d: must be %d or %d", n, TermOneYear, TermThreeYear) + } +} + +// RecommendationTerm renders t in the "1yr"/"3yr" vocabulary that +// common.Recommendation.Term and the provider clients expect. +func (t TermYears) RecommendationTerm() string { + return fmt.Sprintf("%dyr", int(t)) +} + +// SPType is the AWS Savings Plans product family (--include-sp-types in the +// CLI, cmd/main.go:112). +type SPType string + +const ( + SPTypeCompute SPType = "Compute" + SPTypeEC2Instance SPType = "EC2Instance" + SPTypeSageMaker SPType = "SageMaker" + SPTypeDatabase SPType = "Database" +) + +// ValidateSPType returns the typed SPType for s, or an explicit error when s +// is not one of the four AWS Savings Plans product families. +func ValidateSPType(s string) (SPType, error) { + switch SPType(s) { + case SPTypeCompute, SPTypeEC2Instance, SPTypeSageMaker, SPTypeDatabase: + return SPType(s), nil + default: + return "", fmt.Errorf("invalid sp_type %q: must be one of %s, %s, %s, %s", + s, SPTypeCompute, SPTypeEC2Instance, SPTypeSageMaker, SPTypeDatabase) + } +} + +// AZConfig is the RDS deployment topology (single-AZ vs multi-AZ), which +// carries a different price and offering catalogue per +// providers/aws/services/rds/client.go:314-322. +type AZConfig string + +const ( + AZConfigSingleAZ AZConfig = "single-az" + AZConfigMultiAZ AZConfig = "multi-az" +) + +// ValidateAZConfig returns the typed AZConfig for s, or an explicit error +// when s is not single-az or multi-az. RDS's own client refuses to guess this +// value (see the comment at providers/aws/services/rds/client.go:306-322), so +// the MCP boundary must not default it either. +func ValidateAZConfig(s string) (AZConfig, error) { + switch AZConfig(s) { + case AZConfigSingleAZ, AZConfigMultiAZ: + return AZConfig(s), nil + default: + return "", fmt.Errorf("invalid az_config %q: must be %s or %s", s, AZConfigSingleAZ, AZConfigMultiAZ) + } +} + +// ValidatePlatform returns the AWS SDK's own ec2types.RIProductDescription +// enum member for s, or an explicit error when s does not match one of the +// four values that DescribeReservedInstancesOfferings accepts as +// ProductDescription (providers/aws/services/ec2/client.go:419). Reusing the +// SDK's own enum constants -- rather than inventing a "linux"/"windows" +// vocabulary -- means an outbound offering lookup can never carry a bare +// string literal that drifts from what the SDK actually recognises +// (feedback_sdk_enum_string_literals). +func ValidatePlatform(s string) (ec2types.RIProductDescription, error) { + switch ec2types.RIProductDescription(s) { + case ec2types.RIProductDescriptionLinuxUnix, + ec2types.RIProductDescriptionLinuxUnixAmazonVpc, + ec2types.RIProductDescriptionWindows, + ec2types.RIProductDescriptionWindowsAmazonVpc: + return ec2types.RIProductDescription(s), nil + default: + return "", fmt.Errorf("invalid platform %q: must be one of %s, %s, %s, %s", s, + ec2types.RIProductDescriptionLinuxUnix, ec2types.RIProductDescriptionLinuxUnixAmazonVpc, + ec2types.RIProductDescriptionWindows, ec2types.RIProductDescriptionWindowsAmazonVpc) + } +} + +// Tenancy is the EC2 RI tenancy dimension. Values match ec2types.Tenancy +// (providers/aws/services/ec2/client.go:309-318 canonicalises them further, +// but "default"/"dedicated" already pass through unchanged). +type Tenancy string + +const ( + TenancyDefault Tenancy = Tenancy(ec2types.TenancyDefault) + TenancyDedicated Tenancy = Tenancy(ec2types.TenancyDedicated) +) + +// ValidateTenancy returns the typed Tenancy for s, or an explicit error when +// s is neither default nor dedicated. +func ValidateTenancy(s string) (Tenancy, error) { + switch Tenancy(s) { + case TenancyDefault, TenancyDedicated: + return Tenancy(s), nil + default: + return "", fmt.Errorf("invalid tenancy %q: must be %s or %s", s, TenancyDefault, TenancyDedicated) + } +} + +// Scope is the EC2 RI applicability dimension. Values are the lowercase, +// hyphenated form that providers/aws/services/ec2/client.go:330-339 +// (canonicalizeEC2Scope) recognises and normalises to the SDK's +// ec2types.Scope casing ("Region" / "Availability Zone"). +type Scope string + +const ( + ScopeRegion Scope = "region" + ScopeAvailabilityZone Scope = "availability-zone" +) + +// ValidateScope returns the typed Scope for s, or an explicit error when s is +// neither region nor availability-zone. +func ValidateScope(s string) (Scope, error) { + switch Scope(s) { + case ScopeRegion, ScopeAvailabilityZone: + return Scope(s), nil + default: + return "", fmt.Errorf("invalid scope %q: must be %s or %s", s, ScopeRegion, ScopeAvailabilityZone) + } +} diff --git a/mcp/tools/enums_test.go b/mcp/tools/enums_test.go new file mode 100644 index 000000000..763fc33a6 --- /dev/null +++ b/mcp/tools/enums_test.go @@ -0,0 +1,205 @@ +package tools + +import ( + "testing" + + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/stretchr/testify/assert" +) + +func TestValidatePaymentOption(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want PaymentOption + wantErr bool + }{ + {"all-upfront", "all-upfront", PaymentOptionAllUpfront, false}, + {"partial-upfront", "partial-upfront", PaymentOptionPartialUpfront, false}, + {"no-upfront", "no-upfront", PaymentOptionNoUpfront, false}, + {"empty", "", "", true}, + {"unknown", "some-upfront", "", true}, + {"case sensitive", "All-Upfront", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidatePaymentOption(tc.in) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestValidateTermYears(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in int + want TermYears + wantErr bool + }{ + {"one year", 1, TermOneYear, false}, + {"three year", 3, TermThreeYear, false}, + {"zero", 0, 0, true}, + {"two years unsupported", 2, 0, true}, + {"negative", -1, 0, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidateTermYears(tc.in) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestTermYearsRecommendationTerm(t *testing.T) { + t.Parallel() + assert.Equal(t, "1yr", TermOneYear.RecommendationTerm()) + assert.Equal(t, "3yr", TermThreeYear.RecommendationTerm()) +} + +func TestValidateSPType(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want SPType + wantErr bool + }{ + {"compute", "Compute", SPTypeCompute, false}, + {"ec2instance", "EC2Instance", SPTypeEC2Instance, false}, + {"sagemaker", "SageMaker", SPTypeSageMaker, false}, + {"database", "Database", SPTypeDatabase, false}, + {"lowercase rejected", "compute", "", true}, + {"empty", "", "", true}, + {"unknown", "Storage", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidateSPType(tc.in) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestValidateAZConfig(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want AZConfig + wantErr bool + }{ + {"single-az", "single-az", AZConfigSingleAZ, false}, + {"multi-az", "multi-az", AZConfigMultiAZ, false}, + {"empty refuses to guess", "", "", true}, + {"unknown", "triple-az", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidateAZConfig(tc.in) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestValidatePlatform(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want ec2types.RIProductDescription + wantErr bool + }{ + {"linux", "Linux/UNIX", ec2types.RIProductDescriptionLinuxUnix, false}, + {"linux vpc", "Linux/UNIX (Amazon VPC)", ec2types.RIProductDescriptionLinuxUnixAmazonVpc, false}, + {"windows", "Windows", ec2types.RIProductDescriptionWindows, false}, + {"windows vpc", "Windows (Amazon VPC)", ec2types.RIProductDescriptionWindowsAmazonVpc, false}, + {"lowercase rejected", "linux", "", true}, + {"empty", "", "", true}, + {"unknown os", "MacOS", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidatePlatform(tc.in) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestValidateTenancy(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want Tenancy + wantErr bool + }{ + {"default", "default", TenancyDefault, false}, + {"dedicated", "dedicated", TenancyDedicated, false}, + {"empty", "", "", true}, + {"host unsupported", "host", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidateTenancy(tc.in) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestValidateScope(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want Scope + wantErr bool + }{ + {"region", "region", ScopeRegion, false}, + {"availability-zone", "availability-zone", ScopeAvailabilityZone, false}, + {"empty", "", "", true}, + {"sdk casing rejected", "Region", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidateScope(tc.in) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/mcp/tools/list_commitment_actions.go b/mcp/tools/list_commitment_actions.go new file mode 100644 index 000000000..a685aa366 --- /dev/null +++ b/mcp/tools/list_commitment_actions.go @@ -0,0 +1,88 @@ +package tools + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const listCommitmentActionsName = "cudly_list_commitment_actions" + +const listCommitmentActionsDescription = "List every CUDly commitment-purchase and search tool available on this " + + "MCP server, including which ones can execute a REAL purchase (money-affecting) versus which are " + + "search/preview-only, plus example prompts for each. This tool never spends money and takes no parameters -- " + + "start here if you don't already know which cudly_* tool you need." + +// listCommitmentActionsArgs is empty: the tool takes no parameters. +type listCommitmentActionsArgs struct{} + +// ActionEntry is one catalog entry returned by cudly_list_commitment_actions, +// reshaping a Descriptor for JSON output. +type ActionEntry struct { + Name string `json:"name"` + Provider string `json:"provider,omitempty"` + Product string `json:"product,omitempty"` + Action string `json:"action,omitempty"` + Description string `json:"description"` + RealPurchaseEnabled bool `json:"real_purchase_enabled"` + ExamplePrompts []string `json:"example_prompts,omitempty"` +} + +// listCommitmentActionsResult is the tool's structured output. +type listCommitmentActionsResult struct { + Actions []ActionEntry `json:"actions"` +} + +type listCommitmentActionsTool struct { + descriptors []Descriptor +} + +// NewListCommitmentActions builds the cudly_list_commitment_actions tool +// from descriptors -- the same slice of Descriptor values mcp/server.go +// collects from every other tool's Descriptor() method, so this catalog is +// generated from the live registry rather than hand-duplicated in code or +// docs. +func NewListCommitmentActions(descriptors []Descriptor) Registration { + return &listCommitmentActionsTool{descriptors: descriptors} +} + +func (t *listCommitmentActionsTool) Descriptor() Descriptor { + return Descriptor{ + Name: listCommitmentActionsName, + Description: listCommitmentActionsDescription, + ExamplePrompts: []string{ + "What CUDly tools are available?", + "Which purchase tools can spend real money right now?", + "How do I buy AWS EC2 Reserved Instances through CUDly?", + }, + } +} + +func (t *listCommitmentActionsTool) Register(s *mcp.Server) error { + schema, err := BuildInputSchema[listCommitmentActionsArgs](nil) + if err != nil { + return err + } + mcp.AddTool(s, &mcp.Tool{ + Name: listCommitmentActionsName, + Description: listCommitmentActionsDescription, + InputSchema: schema, + }, t.handle) + return nil +} + +func (t *listCommitmentActionsTool) handle(_ context.Context, _ *mcp.CallToolRequest, _ listCommitmentActionsArgs) (*mcp.CallToolResult, listCommitmentActionsResult, error) { + actions := make([]ActionEntry, 0, len(t.descriptors)) + for _, d := range t.descriptors { + actions = append(actions, ActionEntry{ + Name: d.Name, + Provider: d.Provider, + Product: d.Product, + Action: d.Action, + Description: d.Description, + RealPurchaseEnabled: d.RealPurchaseEnabled, + ExamplePrompts: d.ExamplePrompts, + }) + } + return nil, listCommitmentActionsResult{Actions: actions}, nil +} diff --git a/mcp/tools/list_commitment_actions_test.go b/mcp/tools/list_commitment_actions_test.go new file mode 100644 index 000000000..e5b1cf891 --- /dev/null +++ b/mcp/tools/list_commitment_actions_test.go @@ -0,0 +1,48 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListCommitmentActionsReturnsCatalog(t *testing.T) { + t.Parallel() + descriptors := []Descriptor{ + { + Name: "cudly_aws_ec2_ri_purchase", + Provider: "aws", + Product: "ec2", + Action: "ri_purchase", + Description: "spends real money. dry_run recommended first.", + RealPurchaseEnabled: true, + ExamplePrompts: []string{"buy 3 m5.large RIs in us-east-1"}, + }, + { + Name: "cudly_search_recommendations", + Description: "read-only search, never spends money.", + }, + } + + tool := NewListCommitmentActions(descriptors) + impl, ok := tool.(*listCommitmentActionsTool) + require.True(t, ok) + + _, result, err := impl.handle(context.Background(), nil, listCommitmentActionsArgs{}) + require.NoError(t, err) + require.Len(t, result.Actions, 2) + assert.Equal(t, "cudly_aws_ec2_ri_purchase", result.Actions[0].Name) + assert.True(t, result.Actions[0].RealPurchaseEnabled) + assert.False(t, result.Actions[1].RealPurchaseEnabled) +} + +func TestListCommitmentActionsDescriptorItself(t *testing.T) { + t.Parallel() + tool := NewListCommitmentActions(nil) + d := tool.Descriptor() + assert.Equal(t, "cudly_list_commitment_actions", d.Name) + assert.NotEmpty(t, d.Description) + assert.NotEmpty(t, d.ExamplePrompts) +} diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go new file mode 100644 index 000000000..796ab8400 --- /dev/null +++ b/mcp/tools/purchase.go @@ -0,0 +1,150 @@ +package tools + +import ( + "context" + "fmt" + "time" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +// purchaseMode is the outcome of the dry_run/confirm safety gate: either a +// local preview (no provider call) or a real execution (provider call with +// PurchaseSourceMCP + an idempotency token). There is deliberately no third +// "no-op" outcome -- an ambiguous combination of flags is always an error, +// never a silent do-nothing (feedback_no_silent_fallbacks). +type purchaseMode int + +const ( + modePreview purchaseMode = iota + modeExecute +) + +// decidePurchaseMode applies the safety rail from the design doc (§7): a +// real purchase requires confirm=true AND dry_run=false. dry_run=true always +// wins and returns a preview, regardless of confirm, so a caller previewing +// a purchase can leave confirm at its default. The only refusal case is +// dry_run=false with confirm=false: the caller asked for a real purchase +// but did not confirm it, which must surface as an explicit error rather +// than silently downgrading to a preview or silently doing nothing. +func decidePurchaseMode(dryRun, confirm bool) (purchaseMode, error) { + if dryRun { + return modePreview, nil + } + if confirm { + return modeExecute, nil + } + return 0, fmt.Errorf("refusing real purchase: dry_run=false requires confirm=true (got confirm=false); " + + "set dry_run=true to preview this purchase instead, or confirm=true to execute it") +} + +// ResolveClientFunc lazily resolves the provider.ServiceClient that will +// receive the real PurchaseCommitment call. It is a func, not an +// already-resolved client, so ExecutePurchase can prove (and tests can +// assert) that a preview never triggers provider/credential resolution -- +// only modeExecute invokes it. +type ResolveClientFunc func(ctx context.Context) (provider.ServiceClient, error) + +// PurchaseRequest is the provider-agnostic input to ExecutePurchase. Each +// per-service tool handler builds one after validating its own typed +// parameters and constructing the common.Recommendation. +type PurchaseRequest struct { + Region string + Recommendation common.Recommendation + DryRun bool + Confirm bool + ResolveClient ResolveClientFunc +} + +// PurchaseResponse is the structured result returned to the MCP caller for +// both preview and real-purchase outcomes. Error is a string (not the Go +// error) because it crosses the MCP JSON-RPC boundary as tool output, not a +// protocol-level error -- ExecutePurchase itself still returns a Go error +// for gate refusals and provider-call failures. +type PurchaseResponse struct { + Success bool `json:"success"` + DryRun bool `json:"dry_run"` + CommitmentID string `json:"commitment_id,omitempty"` + Cost float64 `json:"cost"` + OnDemandCost float64 `json:"on_demand_cost"` + EstimatedSavings float64 `json:"estimated_savings"` + SavingsPercentage float64 `json:"savings_percentage"` + EffectiveDate string `json:"effective_date,omitempty"` + TermYears int `json:"term_years,omitempty"` + Error string `json:"error,omitempty"` +} + +// idempotencyKeyFor derives a stable per-request key from the fields that +// identify what is being bought, so a caller re-driving the exact same tool +// call (e.g. after a network timeout) reuses the same +// common.DeriveIdempotencyToken output and the provider dedupes the retry +// instead of double-purchasing. A materially different request (different +// count, region, term, ...) always derives a different key. This is a +// request-scoped substitute for the purchase_executions row that the CLI/web +// paths use as their idempotency anchor (pkg/common/tokens.go) -- the MCP +// server has no such row, so the request's own identifying fields play that +// role. +func idempotencyKeyFor(region string, rec common.Recommendation) string { + return fmt.Sprintf("mcp:%s:%s:%s:%s:%s:%d:%s:%s", + rec.Provider, rec.Account, region, rec.Service, rec.ResourceType, rec.Count, rec.Term, rec.PaymentOption) +} + +// ExecutePurchase runs the shared dry_run/confirm safety gate and, for a +// real purchase, resolves the service client and calls PurchaseCommitment +// with PurchaseSourceMCP and a derived idempotency token. It never calls +// ResolveClient in preview mode, so a preview makes zero provider/SDK calls. +func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseResponse, error) { + mode, err := decidePurchaseMode(req.DryRun, req.Confirm) + if err != nil { + return nil, err + } + + rec := req.Recommendation + if mode == modePreview { + return &PurchaseResponse{ + Success: true, + DryRun: true, + Cost: rec.CommitmentCost, + OnDemandCost: rec.OnDemandCost, + EstimatedSavings: rec.EstimatedSavings, + SavingsPercentage: rec.SavingsPercentage, + }, nil + } + + if req.ResolveClient == nil { + return nil, fmt.Errorf("internal error: no ResolveClient configured for real purchase") + } + client, err := req.ResolveClient(ctx) + if err != nil { + return nil, fmt.Errorf("resolve %s service client: %w", rec.Provider, err) + } + + token := common.DeriveIdempotencyToken(idempotencyKeyFor(req.Region, rec), 0) + opts := common.PurchaseOptions{ + Source: common.PurchaseSourceMCP, + IdempotencyToken: token, + } + + result, err := client.PurchaseCommitment(ctx, rec, opts) + if err != nil { + // Full provider error text surfaces to the caller (feedback: + // providers must never swallow the underlying SDK/HTTP error). + return nil, fmt.Errorf("purchase commitment failed: %w", err) + } + + resp := &PurchaseResponse{ + Success: result.Success, + DryRun: result.DryRun, + CommitmentID: result.CommitmentID, + Cost: result.Cost, + OnDemandCost: rec.OnDemandCost, + EstimatedSavings: rec.EstimatedSavings, + SavingsPercentage: rec.SavingsPercentage, + EffectiveDate: result.Timestamp.Format(time.RFC3339), + } + if result.Error != nil { + resp.Error = result.Error.Error() + } + return resp, nil +} diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go new file mode 100644 index 000000000..8cb39ce8b --- /dev/null +++ b/mcp/tools/purchase_test.go @@ -0,0 +1,263 @@ +package tools + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +// fakeServiceClient is a minimal provider.ServiceClient test double. Only +// PurchaseCommitment is exercised by these tests; the rest of the interface +// is implemented trivially to satisfy the type. +type fakeServiceClient struct { + purchaseCalls int + purchaseResult common.PurchaseResult + purchaseErr error + lastOpts common.PurchaseOptions +} + +func (f *fakeServiceClient) GetServiceType() common.ServiceType { return common.ServiceEC2 } +func (f *fakeServiceClient) GetRegion() string { return "us-east-1" } +func (f *fakeServiceClient) GetRecommendations(_ context.Context, _ *common.RecommendationParams) ([]common.Recommendation, error) { + return nil, nil +} +func (f *fakeServiceClient) GetExistingCommitments(_ context.Context) ([]common.Commitment, error) { + return nil, nil +} +func (f *fakeServiceClient) PurchaseCommitment(_ context.Context, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error) { + f.purchaseCalls++ + f.lastOpts = opts + f.purchaseResult.Recommendation = rec + return f.purchaseResult, f.purchaseErr +} +func (f *fakeServiceClient) ValidateOffering(_ context.Context, _ common.Recommendation) error { + return nil +} +func (f *fakeServiceClient) GetOfferingDetails(_ context.Context, _ common.Recommendation) (*common.OfferingDetails, error) { + return nil, nil +} +func (f *fakeServiceClient) GetValidResourceTypes(_ context.Context) ([]string, error) { + return nil, nil +} + +var _ provider.ServiceClient = (*fakeServiceClient)(nil) + +func testRecommendation() common.Recommendation { + return common.Recommendation{ + Provider: common.ProviderAWS, + Account: "123456789012", + Service: common.ServiceEC2, + Region: "us-east-1", + ResourceType: "m5.large", + Count: 3, + Term: "3yr", + PaymentOption: "no-upfront", + OnDemandCost: 1000, + CommitmentCost: 600, + EstimatedSavings: 400, + SavingsPercentage: 40, + } +} + +func TestDecidePurchaseMode(t *testing.T) { + t.Parallel() + cases := []struct { + name string + dryRun bool + confirm bool + want purchaseMode + wantErr bool + }{ + {"dry run wins regardless of confirm", true, false, modePreview, false}, + {"dry run with confirm still previews", true, true, modePreview, false}, + {"confirmed real purchase executes", false, true, modeExecute, false}, + {"unconfirmed real purchase refused", false, false, 0, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := decidePurchaseMode(tc.dryRun, tc.confirm) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestExecutePurchaseDryRunNeverCallsProvider proves the safety rail from +// the design doc: dry_run=true must never invoke ResolveClient (and +// therefore never PurchaseCommitment), even when confirm=true. ResolveClient +// here returns an error if called at all, so any invocation fails the test. +func TestExecutePurchaseDryRunNeverCallsProvider(t *testing.T) { + t.Parallel() + resolveCalled := false + resolve := func(_ context.Context) (provider.ServiceClient, error) { + resolveCalled = true + return nil, errors.New("ResolveClient must not be called in dry_run mode") + } + + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", + Recommendation: testRecommendation(), + DryRun: true, + Confirm: true, + ResolveClient: resolve, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.False(t, resolveCalled, "dry_run=true must never resolve a service client") + assert.True(t, resp.DryRun) + assert.True(t, resp.Success) + assert.Equal(t, 600.0, resp.Cost) + assert.Equal(t, 1000.0, resp.OnDemandCost) + assert.Equal(t, 400.0, resp.EstimatedSavings) + assert.Equal(t, 40.0, resp.SavingsPercentage) +} + +// TestExecutePurchaseUnconfirmedRealPurchaseRefused proves confirm=false +// refuses a real purchase (dry_run=false) with a structured error rather +// than a silent no-op, and that ResolveClient is never invoked either. +func TestExecutePurchaseUnconfirmedRealPurchaseRefused(t *testing.T) { + t.Parallel() + resolveCalled := false + resolve := func(_ context.Context) (provider.ServiceClient, error) { + resolveCalled = true + return nil, nil + } + + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", + Recommendation: testRecommendation(), + DryRun: false, + Confirm: false, + ResolveClient: resolve, + }) + + require.Error(t, err) + assert.Nil(t, resp) + assert.False(t, resolveCalled) + assert.Contains(t, err.Error(), "confirm=true") +} + +// TestExecutePurchaseRealPurchaseCallsProviderWithMCPSource proves a +// confirmed real purchase resolves the client, calls PurchaseCommitment +// exactly once, and stamps PurchaseSourceMCP + a non-empty idempotency +// token -- never a caller-suppliable source string. +func TestExecutePurchaseRealPurchaseCallsProviderWithMCPSource(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{ + purchaseResult: common.PurchaseResult{ + Success: true, + CommitmentID: "ri-12345", + Cost: 600, + }, + } + resolve := func(_ context.Context) (provider.ServiceClient, error) { + return fake, nil + } + + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", + Recommendation: testRecommendation(), + DryRun: false, + Confirm: true, + ResolveClient: resolve, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, 1, fake.purchaseCalls) + assert.Equal(t, common.PurchaseSourceMCP, fake.lastOpts.Source) + assert.NotEmpty(t, fake.lastOpts.IdempotencyToken) + assert.True(t, resp.Success) + assert.Equal(t, "ri-12345", resp.CommitmentID) + assert.False(t, resp.DryRun) +} + +// TestExecutePurchaseSameRequestDerivesSameToken proves idempotencyKeyFor +// (and therefore the derived token) is deterministic for the same +// identifying fields, so a retried call with identical arguments dedupes at +// the provider rather than double-purchasing. +func TestExecutePurchaseSameRequestDerivesSameToken(t *testing.T) { + t.Parallel() + rec := testRecommendation() + fake1 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + fake2 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + + _, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake1, nil }, + }) + require.NoError(t, err) + + _, err = ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake2, nil }, + }) + require.NoError(t, err) + + assert.Equal(t, fake1.lastOpts.IdempotencyToken, fake2.lastOpts.IdempotencyToken) + + // A materially different request (different count) must derive a + // different token. + rec2 := rec + rec2.Count = 4 + fake3 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + _, err = ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec2, DryRun: false, Confirm: true, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake3, nil }, + }) + require.NoError(t, err) + assert.NotEqual(t, fake1.lastOpts.IdempotencyToken, fake3.lastOpts.IdempotencyToken) +} + +// TestExecutePurchaseProviderErrorSurfaced proves a provider-side purchase +// failure surfaces the full underlying error text rather than being +// swallowed. +func TestExecutePurchaseProviderErrorSurfaced(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseErr: errors.New("AWS API: InsufficientInstanceCapacity")} + resolve := func(_ context.Context) (provider.ServiceClient, error) { return fake, nil } + + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", + Recommendation: testRecommendation(), + DryRun: false, + Confirm: true, + ResolveClient: resolve, + }) + + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "InsufficientInstanceCapacity") +} + +// TestExecutePurchaseResolveClientErrorSurfaced proves a client-resolution +// failure (e.g. bad credentials) surfaces its error text too. +func TestExecutePurchaseResolveClientErrorSurfaced(t *testing.T) { + t.Parallel() + resolve := func(_ context.Context) (provider.ServiceClient, error) { + return nil, errors.New("no AWS credentials found") + } + + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", + Recommendation: testRecommendation(), + DryRun: false, + Confirm: true, + ResolveClient: resolve, + }) + + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "no AWS credentials found") +} diff --git a/mcp/tools/registry.go b/mcp/tools/registry.go new file mode 100644 index 000000000..d661a1c0a --- /dev/null +++ b/mcp/tools/registry.go @@ -0,0 +1,41 @@ +package tools + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +// Descriptor is the source-of-truth metadata for one MCP tool. Every tool +// file builds one and both mcp/server.go (to know what to register) and +// cudly_list_commitment_actions (to know what to advertise) read it, so the +// live tool set and the discoverability catalog can never drift apart -- +// there is exactly one place each tool's name/description/example prompts +// are written. +type Descriptor struct { + // Name is the MCP tool name, e.g. "cudly_aws_ec2_ri_purchase". + Name string + // Provider is "aws", "azure", "gcp", or "" for provider-agnostic + // meta-tools (cudly_list_commitment_actions, cudly_search_recommendations). + Provider string + // Product is the service the tool acts on, e.g. "ec2", "rds", "compute". + Product string + // Action is what the tool does, e.g. "ri_purchase", "cud_purchase", "search". + Action string + // Description is the tool's full MCP description, shared verbatim with + // the live mcp.Tool registration so the two can never disagree. + Description string + // RealPurchaseEnabled reports whether this tool can execute a real, + // money-spending purchase today (dry_run=false, confirm=true). false for + // read-only tools and for tools shipped dry-run-only pending a + // prerequisite fix (see the Azure/GCP tool comments). + RealPurchaseEnabled bool + // ExamplePrompts are 2-3 natural-language prompts that would plausibly + // invoke this tool, surfaced by cudly_list_commitment_actions so a + // session that doesn't know the tool name yet can find it. + ExamplePrompts []string +} + +// Registration is implemented by every tool file. Descriptor feeds the +// catalog; Register performs the live mcp.AddTool (or mcp.Server.AddTool) +// call that wires the tool's schema and handler onto the server. +type Registration interface { + Descriptor() Descriptor + Register(s *mcp.Server) error +} diff --git a/mcp/tools/schema.go b/mcp/tools/schema.go new file mode 100644 index 000000000..b6227b661 --- /dev/null +++ b/mcp/tools/schema.go @@ -0,0 +1,57 @@ +package tools + +import ( + "encoding/json" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" +) + +// FieldOverride declares JSON Schema refinements -- an explicit enum +// membership and/or a documented default -- for one property of an +// otherwise auto-inferred schema. Centralizing this in one helper +// (BuildInputSchema) means every tool declares its enum/default once, next +// to its Go struct, instead of re-implementing schema post-processing per +// tool. +type FieldOverride struct { + // Enum, when non-empty, restricts the property to these exact values. + Enum []any + // Default, when non-nil, is recorded on the schema as the property's + // documented default so a caller inspecting the tool (or an MCP client + // that surfaces schema defaults in its UI) can see it without reading + // the tool description prose. It does NOT, by itself, cause the value to + // be applied when the caller omits the field -- each tool's handler + // applies its own default explicitly (see the dry_run/confirm pattern in + // purchase.go) so "omitted" is never silently confused with "false". + Default any +} + +// BuildInputSchema infers the JSON Schema for T via jsonschema.For, then +// applies the given per-field overrides by JSON field name. It returns an +// error -- rather than silently skipping -- when an override names a field +// that does not exist on T, so a typo in the override map is caught at +// server-startup / test time instead of quietly shipping an unconstrained +// schema for a money-affecting field. +func BuildInputSchema[T any](overrides map[string]FieldOverride) (*jsonschema.Schema, error) { + schema, err := jsonschema.For[T](nil) + if err != nil { + return nil, fmt.Errorf("infer schema for %T: %w", *new(T), err) + } + for field, ov := range overrides { + prop, ok := schema.Properties[field] + if !ok { + return nil, fmt.Errorf("schema override for unknown field %q (does the json tag match?)", field) + } + if len(ov.Enum) > 0 { + prop.Enum = ov.Enum + } + if ov.Default != nil { + b, err := json.Marshal(ov.Default) + if err != nil { + return nil, fmt.Errorf("marshal default for field %q: %w", field, err) + } + prop.Default = b + } + } + return schema, nil +} diff --git a/mcp/tools/schema_test.go b/mcp/tools/schema_test.go new file mode 100644 index 000000000..153acad68 --- /dev/null +++ b/mcp/tools/schema_test.go @@ -0,0 +1,58 @@ +package tools + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type schemaTestArgs struct { + Region string `json:"region" jsonschema:"AWS region"` + TermYears int `json:"term_years" jsonschema:"commitment term in years"` + PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase"` +} + +func TestBuildInputSchemaAppliesEnumAndDefault(t *testing.T) { + t.Parallel() + trueDefault := true + schema, err := BuildInputSchema[schemaTestArgs](map[string]FieldOverride{ + "term_years": {Enum: []any{1, 3}}, + "payment_option": {Enum: []any{"all-upfront", "partial-upfront", "no-upfront"}}, + "dry_run": {Default: trueDefault}, + }) + require.NoError(t, err) + + require.Contains(t, schema.Properties, "term_years") + assert.Equal(t, []any{1, 3}, schema.Properties["term_years"].Enum) + + require.Contains(t, schema.Properties, "payment_option") + assert.Equal(t, []any{"all-upfront", "partial-upfront", "no-upfront"}, schema.Properties["payment_option"].Enum) + + require.Contains(t, schema.Properties, "dry_run") + var gotDefault bool + require.NoError(t, json.Unmarshal(schema.Properties["dry_run"].Default, &gotDefault)) + assert.True(t, gotDefault) + + // region carries no override and must stay unconstrained. + require.Contains(t, schema.Properties, "region") + assert.Empty(t, schema.Properties["region"].Enum) +} + +func TestBuildInputSchemaUnknownFieldErrors(t *testing.T) { + t.Parallel() + _, err := BuildInputSchema[schemaTestArgs](map[string]FieldOverride{ + "instance_type": {Enum: []any{"m5.large"}}, // not a field on schemaTestArgs + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "instance_type") +} + +func TestBuildInputSchemaNilOverridesIsNoOp(t *testing.T) { + t.Parallel() + schema, err := BuildInputSchema[schemaTestArgs](nil) + require.NoError(t, err) + require.Contains(t, schema.Properties, "region") +} From b9fd9b893186479b2a33c3b1c86a96da816f7456 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:24:48 +0200 Subject: [PATCH 05/83] feat(mcp): search-recommendations tool Adds cudly_search_recommendations, a read-only wrapper over Provider.GetRecommendationsClient().GetRecommendations() -- the same call cmd/multi_service.go makes before purchasing. Provider name, payment option, term, and Savings Plans type filters are validated against the typed enums from the previous commit; the requested service is checked against the provider's own GetSupportedServices() so the tool can never drift from what each provider actually supports. No dry_run/confirm parameters: the tool never purchases anything, so there is nothing to gate. --- mcp/server.go | 4 +- mcp/tools/search_recommendations.go | 224 +++++++++++++++++++++++ mcp/tools/search_recommendations_test.go | 178 ++++++++++++++++++ 3 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 mcp/tools/search_recommendations.go create mode 100644 mcp/tools/search_recommendations_test.go diff --git a/mcp/server.go b/mcp/server.go index 942768ef3..0d7e81bee 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -21,7 +21,9 @@ const ServerName = "cudly-mcp" // last, once it has every other tool's Descriptor to build the catalog // from). func registrations() []tools.Registration { - return []tools.Registration{} + return []tools.Registration{ + tools.NewSearchRecommendationsTool(), + } } // NewServer builds the CUDly MCP server with every tool registered. version diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go new file mode 100644 index 000000000..a09cb9950 --- /dev/null +++ b/mcp/tools/search_recommendations.go @@ -0,0 +1,224 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +const searchRecommendationsName = "cudly_search_recommendations" + +const searchRecommendationsDescription = "Search for reserved-capacity purchase recommendations (RI/SP/CUD) " + + "across AWS, Azure, or GCP. Read-only: makes no purchase and spends no money -- there is no dry_run or " + + "confirm parameter because nothing is ever bought. Use this first to find what to buy, then feed a result's " + + "region/resource_type/count into the matching cudly____purchase tool." + +// searchRecommendationsArgs mirrors common.RecommendationParams, adding the +// provider selector and the optional per-call credential overrides from the +// design doc's §4 config-exposure model (aws_profile / +// azure_subscription_id / gcp_project_id). +type searchRecommendationsArgs struct { + Provider string `json:"provider" jsonschema:"cloud provider to search"` + Service string `json:"service" jsonschema:"service to search, e.g. ec2, rds, elasticache, compute, computeengine"` + Region string `json:"region,omitempty" jsonschema:"region to search; omit for account/global-level services such as Savings Plans"` + LookbackPeriod string `json:"lookback_period,omitempty" jsonschema:"cost/usage lookback window backing the recommendation"` + TermYears int `json:"term_years,omitempty" jsonschema:"filter to a specific commitment term; omit to search all terms"` + PaymentOption string `json:"payment_option,omitempty" jsonschema:"filter to a specific payment schedule; omit to search all"` + AccountFilter []string `json:"account_filter,omitempty" jsonschema:"restrict the search to these account/subscription/project IDs"` + IncludeSPTypes []string `json:"include_sp_types,omitempty" jsonschema:"AWS Savings Plans types to include; omit for all"` + ExcludeSPTypes []string `json:"exclude_sp_types,omitempty" jsonschema:"AWS Savings Plans types to exclude"` + AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` + AzureSubscriptionID string `json:"azure_subscription_id,omitempty" jsonschema:"Azure subscription ID override; default uses AZURE_SUBSCRIPTION_ID"` + GCPProjectID string `json:"gcp_project_id,omitempty" jsonschema:"GCP project ID override; default uses ambient project"` +} + +// searchRecommendationsResult is the tool's structured output. +type searchRecommendationsResult struct { + Count int `json:"count"` + Recommendations []common.Recommendation `json:"recommendations"` +} + +type searchRecommendationsTool struct { + // createProvider is a seam over provider.CreateProvider so tests can + // inject a fake Provider without resolving real cloud credentials. + createProvider func(name string, cfg *provider.ProviderConfig) (provider.Provider, error) +} + +// NewSearchRecommendationsTool builds the cudly_search_recommendations tool. +func NewSearchRecommendationsTool() Registration { + return &searchRecommendationsTool{createProvider: provider.CreateProvider} +} + +func (t *searchRecommendationsTool) Descriptor() Descriptor { + return Descriptor{ + Name: searchRecommendationsName, + Description: searchRecommendationsDescription, + Action: "search", + ExamplePrompts: []string{ + "Search for AWS EC2 RI recommendations in us-east-1", + "What RDS Reserved Instance recommendations exist for account 123456789012?", + "Find GCP Compute Engine committed-use discount recommendations", + }, + } +} + +func (t *searchRecommendationsTool) Register(s *mcp.Server) error { + schema, err := BuildInputSchema[searchRecommendationsArgs](map[string]FieldOverride{ + "provider": {Enum: []any{string(common.ProviderAWS), string(common.ProviderAzure), string(common.ProviderGCP)}}, + "lookback_period": {Enum: []any{"7d", "30d", "60d"}}, + }) + if err != nil { + return err + } + mcp.AddTool(s, &mcp.Tool{ + Name: searchRecommendationsName, + Description: searchRecommendationsDescription, + InputSchema: schema, + }, t.handle) + return nil +} + +func (t *searchRecommendationsTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args searchRecommendationsArgs) (*mcp.CallToolResult, searchRecommendationsResult, error) { + providerType, term, err := validateSearchArgs(args) + if err != nil { + return nil, searchRecommendationsResult{}, err + } + + prov, err := t.createProvider(string(providerType), providerConfigFromArgs(providerType, args)) + if err != nil { + return nil, searchRecommendationsResult{}, fmt.Errorf("create %s provider: %w", providerType, err) + } + + service, err := validateSupportedService(prov, args.Service) + if err != nil { + return nil, searchRecommendationsResult{}, err + } + + recClient, err := prov.GetRecommendationsClient(ctx) + if err != nil { + return nil, searchRecommendationsResult{}, fmt.Errorf("get %s recommendations client: %w", providerType, err) + } + + recs, err := recClient.GetRecommendations(ctx, recommendationParamsFromArgs(service, term, args)) + if err != nil { + return nil, searchRecommendationsResult{}, fmt.Errorf("get recommendations: %w", err) + } + + return nil, searchRecommendationsResult{Count: len(recs), Recommendations: recs}, nil +} + +// validateSearchArgs validates every money-neutral-but-still-typed field on +// args that does not require a live provider (provider name, payment +// option, term, Savings Plans type filters), returning the typed provider +// name and the normalised Recommendation term string ("1yr"/"3yr", or "" +// when args.TermYears was omitted). +func validateSearchArgs(args searchRecommendationsArgs) (common.ProviderType, string, error) { + providerType, err := validateProviderName(args.Provider) + if err != nil { + return "", "", err + } + + if args.PaymentOption != "" { + if _, err := ValidatePaymentOption(args.PaymentOption); err != nil { + return "", "", err + } + } + + term := "" + if args.TermYears != 0 { + ty, err := ValidateTermYears(args.TermYears) + if err != nil { + return "", "", err + } + term = ty.RecommendationTerm() + } + + if err := validateSPTypeFilters(args.IncludeSPTypes, args.ExcludeSPTypes); err != nil { + return "", "", err + } + + return providerType, term, nil +} + +// validateSPTypeFilters validates every entry of include/exclude against +// the AWS Savings Plans type enum, naming which filter a bad entry came +// from. +func validateSPTypeFilters(include, exclude []string) error { + for _, sp := range include { + if _, err := ValidateSPType(sp); err != nil { + return fmt.Errorf("include_sp_types: %w", err) + } + } + for _, sp := range exclude { + if _, err := ValidateSPType(sp); err != nil { + return fmt.Errorf("exclude_sp_types: %w", err) + } + } + return nil +} + +// providerConfigFromArgs builds the provider.ProviderConfig for the given +// provider from the tool's per-call credential override fields (design §4). +func providerConfigFromArgs(providerType common.ProviderType, args searchRecommendationsArgs) *provider.ProviderConfig { + return &provider.ProviderConfig{ + Name: string(providerType), + AWSProfile: args.AWSProfile, + AzureSubscriptionID: args.AzureSubscriptionID, + GCPProjectID: args.GCPProjectID, + Region: args.Region, + } +} + +// recommendationParamsFromArgs builds the common.RecommendationParams for +// the already-validated service and term. +func recommendationParamsFromArgs(service common.ServiceType, term string, args searchRecommendationsArgs) *common.RecommendationParams { + return &common.RecommendationParams{ + Service: service, + Region: args.Region, + LookbackPeriod: args.LookbackPeriod, + Term: term, + PaymentOption: args.PaymentOption, + AccountFilter: args.AccountFilter, + IncludeSPTypes: args.IncludeSPTypes, + ExcludeSPTypes: args.ExcludeSPTypes, + } +} + +// validateProviderName returns the typed common.ProviderType for s, or an +// explicit error when s is not aws, azure, or gcp. +func validateProviderName(s string) (common.ProviderType, error) { + switch common.ProviderType(s) { + case common.ProviderAWS, common.ProviderAzure, common.ProviderGCP: + return common.ProviderType(s), nil + default: + return "", fmt.Errorf("invalid provider %q: must be one of %s, %s, %s", + s, common.ProviderAWS, common.ProviderAzure, common.ProviderGCP) + } +} + +// validateSupportedService checks service against prov's own +// GetSupportedServices() -- the provider's live list, not a hardcoded +// mirror of it -- so this tool never drifts from what each provider +// actually supports. +func validateSupportedService(prov provider.Provider, service string) (common.ServiceType, error) { + if service == "" { + return "", fmt.Errorf("service is required") + } + want := common.ServiceType(service) + supported := prov.GetSupportedServices() + for _, s := range supported { + if s == want { + return want, nil + } + } + names := make([]string, len(supported)) + for i, s := range supported { + names[i] = s.String() + } + return "", fmt.Errorf("invalid service %q for provider %s: must be one of %s", service, prov.Name(), strings.Join(names, ", ")) +} diff --git a/mcp/tools/search_recommendations_test.go b/mcp/tools/search_recommendations_test.go new file mode 100644 index 000000000..ad074f846 --- /dev/null +++ b/mcp/tools/search_recommendations_test.go @@ -0,0 +1,178 @@ +package tools + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +// fakeRecommendationsClient is a minimal provider.RecommendationsClient test +// double; only GetRecommendations is exercised by search_recommendations. +type fakeRecommendationsClient struct { + lastParams *common.RecommendationParams + recs []common.Recommendation + err error +} + +func (f *fakeRecommendationsClient) GetRecommendations(_ context.Context, params *common.RecommendationParams) ([]common.Recommendation, error) { + f.lastParams = params + return f.recs, f.err +} +func (f *fakeRecommendationsClient) GetRecommendationsForService(_ context.Context, _ common.ServiceType) ([]common.Recommendation, error) { + return f.recs, f.err +} +func (f *fakeRecommendationsClient) GetAllRecommendations(_ context.Context) ([]common.Recommendation, error) { + return f.recs, f.err +} + +var _ provider.RecommendationsClient = (*fakeRecommendationsClient)(nil) + +// fakeProvider is a minimal provider.Provider test double. +type fakeProvider struct { + name string + services []common.ServiceType + recClient provider.RecommendationsClient + recErr error +} + +func (f *fakeProvider) Name() string { return f.name } +func (f *fakeProvider) DisplayName() string { return f.name } +func (f *fakeProvider) IsConfigured() bool { return true } +func (f *fakeProvider) GetCredentials() (provider.Credentials, error) { + return nil, nil +} +func (f *fakeProvider) ValidateCredentials(_ context.Context) error { return nil } +func (f *fakeProvider) GetAccounts(_ context.Context) ([]common.Account, error) { + return nil, nil +} +func (f *fakeProvider) GetRegions(_ context.Context) ([]common.Region, error) { + return nil, nil +} +func (f *fakeProvider) GetDefaultRegion() string { return "us-east-1" } +func (f *fakeProvider) GetSupportedServices() []common.ServiceType { + return f.services +} +func (f *fakeProvider) GetServiceClient(_ context.Context, _ common.ServiceType, _ string) (provider.ServiceClient, error) { + return nil, nil +} +func (f *fakeProvider) GetRecommendationsClient(_ context.Context) (provider.RecommendationsClient, error) { + return f.recClient, f.recErr +} + +var _ provider.Provider = (*fakeProvider)(nil) + +func newTestSearchTool(fp *fakeProvider) *searchRecommendationsTool { + return &searchRecommendationsTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return fp, nil + }, + } +} + +func TestSearchRecommendationsHappyPath(t *testing.T) { + t.Parallel() + recs := []common.Recommendation{{Provider: common.ProviderAWS, ResourceType: "m5.large", Count: 2}} + client := &fakeRecommendationsClient{recs: recs} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + Region: "us-east-1", + }) + + require.NoError(t, err) + assert.Equal(t, 1, result.Count) + assert.Equal(t, recs, result.Recommendations) + require.NotNil(t, client.lastParams) + assert.Equal(t, common.ServiceEC2, client.lastParams.Service) + assert.Equal(t, "us-east-1", client.lastParams.Region) +} + +func TestSearchRecommendationsInvalidProvider(t *testing.T) { + t.Parallel() + tool := newTestSearchTool(&fakeProvider{}) + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "openstack", + Service: "ec2", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid provider") +} + +func TestSearchRecommendationsUnsupportedService(t *testing.T) { + t.Parallel() + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2, common.ServiceRDS}} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "cosmosdb", // not an AWS service + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid service") +} + +func TestSearchRecommendationsInvalidTermYears(t *testing.T) { + t.Parallel() + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + TermYears: 2, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid term_years") +} + +func TestSearchRecommendationsInvalidSPType(t *testing.T) { + t.Parallel() + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceSavingsPlansAll}} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: string(common.ServiceSavingsPlansAll), + IncludeSPTypes: []string{"NotARealType"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "include_sp_types") +} + +func TestSearchRecommendationsProviderErrorSurfaced(t *testing.T) { + t.Parallel() + tool := &searchRecommendationsTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return nil, errors.New("no AWS credentials found") + }, + } + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no AWS credentials found") +} + +func TestSearchRecommendationsClientErrorSurfaced(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{err: errors.New("Cost Explorer API throttled")} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "Cost Explorer API throttled") +} From fb0e4e37d23315d886cba3de09bae9950479ef1e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:29:06 +0200 Subject: [PATCH 06/83] feat(mcp): aws ec2 ri purchase tool Adds cudly_aws_ec2_ri_purchase, the first real-purchase tool, wired through the dry_run/confirm gate from mcp/tools/purchase.go. Term, payment option, and the EC2-specific platform/tenancy/scope dimensions (required by providers/aws/services/ec2/client.go's offering lookup) are validated against typed enums; platform/tenancy/scope default to the common case (Linux/UNIX, default tenancy, region scope) when omitted, documented in the schema rather than silently applied. Every real purchase stamps common.PurchaseSourceMCP and a deterministic idempotency token derived from the request's own identifying fields, so a retried call with identical arguments dedupes at the provider instead of double-purchasing. --- mcp/server.go | 1 + mcp/tools/aws_ec2_ri.go | 217 +++++++++++++++++++++++++++++++++++ mcp/tools/aws_ec2_ri_test.go | 199 ++++++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+) create mode 100644 mcp/tools/aws_ec2_ri.go create mode 100644 mcp/tools/aws_ec2_ri_test.go diff --git a/mcp/server.go b/mcp/server.go index 0d7e81bee..e134a1bdc 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -23,6 +23,7 @@ const ServerName = "cudly-mcp" func registrations() []tools.Registration { return []tools.Registration{ tools.NewSearchRecommendationsTool(), + tools.NewAWSEC2RIPurchaseTool(), } } diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go new file mode 100644 index 000000000..4cfe71e3b --- /dev/null +++ b/mcp/tools/aws_ec2_ri.go @@ -0,0 +1,217 @@ +package tools + +import ( + "context" + "fmt" + + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +const awsEC2RIPurchaseName = "cudly_aws_ec2_ri_purchase" + +const awsEC2RIPurchaseDescription = "Purchase AWS EC2 Reserved Instances. THIS SPENDS REAL MONEY when " + + "dry_run=false and confirm=true. Always call with dry_run=true first (the default) to validate your " + + "parameters before committing; a dry_run response never contacts AWS and never spends money. Search first " + + "with cudly_search_recommendations to find a region/instance_type/count worth reserving." + +// ec2RIPurchaseArgs is the input schema for cudly_aws_ec2_ri_purchase. term +// and payment_option map onto common.Recommendation.Term/PaymentOption; +// platform/tenancy/scope map onto common.ComputeDetails, which +// providers/aws/services/ec2/client.go:401-424 requires (Platform empty is a +// hard error there) -- they default to the overwhelmingly common case +// (on-demand Linux, shared tenancy, region-scoped) but are always visible in +// the schema and can be overridden per call. +type ec2RIPurchaseArgs struct { + Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` + InstanceType string `json:"instance_type" jsonschema:"EC2 instance type, e.g. m5.large"` + Count int `json:"count" jsonschema:"number of instances to reserve, must be > 0"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` + Platform string `json:"platform,omitempty" jsonschema:"RI product description (operating system); defaults to Linux/UNIX"` + Tenancy string `json:"tenancy,omitempty" jsonschema:"instance tenancy; defaults to default (shared)"` + 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"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` +} + +type awsEC2RIPurchaseTool struct { + createProvider func(name string, cfg *provider.ProviderConfig) (provider.Provider, error) +} + +// NewAWSEC2RIPurchaseTool builds the cudly_aws_ec2_ri_purchase tool. +func NewAWSEC2RIPurchaseTool() Registration { + return &awsEC2RIPurchaseTool{createProvider: provider.CreateProvider} +} + +func (t *awsEC2RIPurchaseTool) Descriptor() Descriptor { + return Descriptor{ + Name: awsEC2RIPurchaseName, + Provider: "aws", + Product: "ec2", + Action: "ri_purchase", + Description: awsEC2RIPurchaseDescription, + RealPurchaseEnabled: true, + ExamplePrompts: []string{ + "Preview buying 3 m5.large 3-year no-upfront RIs in us-east-1", + "Buy 2 r6g.large Reserved Instances in eu-west-1 for real, 1-year all-upfront", + }, + } +} + +func (t *awsEC2RIPurchaseTool) Register(s *mcp.Server) error { + schema, err := BuildInputSchema[ec2RIPurchaseArgs](map[string]FieldOverride{ + "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, + "payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionPartialUpfront), string(PaymentOptionNoUpfront)}}, + "scope": {Enum: []any{string(ScopeRegion), string(ScopeAvailabilityZone)}, Default: string(ScopeRegion)}, + "tenancy": {Enum: []any{string(TenancyDefault), string(TenancyDedicated)}, Default: string(TenancyDefault)}, + "dry_run": {Default: true}, + "confirm": {Default: false}, + }) + if err != nil { + return err + } + mcp.AddTool(s, &mcp.Tool{ + Name: awsEC2RIPurchaseName, + Description: awsEC2RIPurchaseDescription, + InputSchema: schema, + }, t.handle) + return nil +} + +func (t *awsEC2RIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args ec2RIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { + rec, dryRun, confirm, err := ec2RecommendationFromArgs(args) + if err != nil { + return nil, PurchaseResponse{}, err + } + + resp, err := ExecutePurchase(ctx, PurchaseRequest{ + Region: args.Region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args), + }) + if err != nil { + return nil, PurchaseResponse{}, err + } + return nil, *resp, nil +} + +// ec2ComputeDimensions holds the validated, possibly-defaulted EC2 RI +// dimensions that do not vary by resource/count/term -- split out of +// ec2RecommendationFromArgs to keep that function under the pre-commit +// gocyclo threshold. +type ec2ComputeDimensions struct { + platform ec2types.RIProductDescription + tenancy Tenancy + scope Scope +} + +// resolveEC2ComputeDimensions validates the optional platform/tenancy/scope +// fields, applying their documented defaults (Linux/UNIX, default tenancy, +// region scope) when the caller omits them. +func resolveEC2ComputeDimensions(args ec2RIPurchaseArgs) (ec2ComputeDimensions, error) { + dims := ec2ComputeDimensions{ + platform: ec2types.RIProductDescriptionLinuxUnix, + tenancy: TenancyDefault, + scope: ScopeRegion, + } + var err error + if args.Platform != "" { + if dims.platform, err = ValidatePlatform(args.Platform); err != nil { + return ec2ComputeDimensions{}, err + } + } + if args.Tenancy != "" { + if dims.tenancy, err = ValidateTenancy(args.Tenancy); err != nil { + return ec2ComputeDimensions{}, err + } + } + if args.Scope != "" { + if dims.scope, err = ValidateScope(args.Scope); err != nil { + return ec2ComputeDimensions{}, err + } + } + return dims, nil +} + +// effectiveDryRunConfirm applies the dry_run=true / confirm=false defaults: +// Go's zero value for bool cannot distinguish "caller omitted the field" +// from "caller explicitly set it false", so both flags are pointers and this +// is the single place that resolves them to concrete booleans. +func effectiveDryRunConfirm(args ec2RIPurchaseArgs) (dryRun, confirm bool) { + dryRun = true + if args.DryRun != nil { + dryRun = *args.DryRun + } + if args.Confirm != nil { + confirm = *args.Confirm + } + return dryRun, confirm +} + +// ec2RecommendationFromArgs validates every field of args and builds the +// common.Recommendation to purchase, plus the effective dry_run/confirm +// booleans. +func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (common.Recommendation, bool, bool, error) { + if args.Region == "" { + return common.Recommendation{}, false, false, fmt.Errorf("region is required") + } + if args.InstanceType == "" { + return common.Recommendation{}, false, false, fmt.Errorf("instance_type is required") + } + if args.Count <= 0 { + return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) + } + term, err := ValidateTermYears(args.TermYears) + if err != nil { + return common.Recommendation{}, false, false, err + } + paymentOption, err := ValidatePaymentOption(args.PaymentOption) + if err != nil { + return common.Recommendation{}, false, false, err + } + dims, err := resolveEC2ComputeDimensions(args) + if err != nil { + return common.Recommendation{}, false, false, err + } + + rec := common.Recommendation{ + Provider: common.ProviderAWS, + Service: common.ServiceEC2, + Region: args.Region, + ResourceType: args.InstanceType, + Count: args.Count, + CommitmentType: common.CommitmentReservedInstance, + Term: term.RecommendationTerm(), + PaymentOption: string(paymentOption), + Details: &common.ComputeDetails{ + InstanceType: args.InstanceType, + Platform: string(dims.platform), + Tenancy: string(dims.tenancy), + Scope: string(dims.scope), + }, + } + + dryRun, confirm := effectiveDryRunConfirm(args) + return rec, dryRun, confirm, nil +} + +// resolveClient returns the ResolveClientFunc that ExecutePurchase invokes +// only for a real purchase, so provider/credential resolution is deferred +// until after the dry_run/confirm gate has already decided to execute. +func (t *awsEC2RIPurchaseTool) resolveClient(args ec2RIPurchaseArgs) ResolveClientFunc { + return func(ctx context.Context) (provider.ServiceClient, error) { + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: args.Region} + prov, err := t.createProvider(string(common.ProviderAWS), cfg) + if err != nil { + return nil, err + } + return prov.GetServiceClient(ctx, common.ServiceEC2, args.Region) + } +} diff --git a/mcp/tools/aws_ec2_ri_test.go b/mcp/tools/aws_ec2_ri_test.go new file mode 100644 index 000000000..f3517ddec --- /dev/null +++ b/mcp/tools/aws_ec2_ri_test.go @@ -0,0 +1,199 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +func boolPtr(b bool) *bool { return &b } + +func validEC2Args() ec2RIPurchaseArgs { + return ec2RIPurchaseArgs{ + Region: "us-east-1", + InstanceType: "m5.large", + Count: 3, + TermYears: 3, + PaymentOption: "no-upfront", + } +} + +func TestEC2RecommendationFromArgsDefaults(t *testing.T) { + t.Parallel() + rec, dryRun, confirm, err := ec2RecommendationFromArgs(validEC2Args()) + require.NoError(t, err) + assert.True(t, dryRun, "dry_run must default to true") + assert.False(t, confirm, "confirm must default to false") + assert.Equal(t, common.ProviderAWS, rec.Provider) + assert.Equal(t, common.ServiceEC2, rec.Service) + assert.Equal(t, "m5.large", rec.ResourceType) + assert.Equal(t, 3, rec.Count) + assert.Equal(t, "3yr", rec.Term) + assert.Equal(t, "no-upfront", rec.PaymentOption) + details, ok := rec.Details.(*common.ComputeDetails) + require.True(t, ok, "Details must be *common.ComputeDetails") + assert.Equal(t, "Linux/UNIX", details.Platform) + assert.Equal(t, "default", details.Tenancy) + assert.Equal(t, "region", details.Scope) +} + +func TestEC2RecommendationFromArgsExplicitFlags(t *testing.T) { + t.Parallel() + args := validEC2Args() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + args.Platform = "Windows" + args.Tenancy = "dedicated" + args.Scope = "availability-zone" + + rec, dryRun, confirm, err := ec2RecommendationFromArgs(args) + require.NoError(t, err) + assert.False(t, dryRun) + assert.True(t, confirm) + details, ok := rec.Details.(*common.ComputeDetails) + require.True(t, ok) + assert.Equal(t, "Windows", details.Platform) + assert.Equal(t, "dedicated", details.Tenancy) + assert.Equal(t, "availability-zone", details.Scope) +} + +func TestEC2RecommendationFromArgsMissingRequiredFields(t *testing.T) { + t.Parallel() + cases := []struct { + name string + mutate func(*ec2RIPurchaseArgs) + errSub string + }{ + {"missing region", func(a *ec2RIPurchaseArgs) { a.Region = "" }, "region is required"}, + {"missing instance_type", func(a *ec2RIPurchaseArgs) { a.InstanceType = "" }, "instance_type is required"}, + {"zero count", func(a *ec2RIPurchaseArgs) { a.Count = 0 }, "count must be"}, + {"negative count", func(a *ec2RIPurchaseArgs) { a.Count = -1 }, "count must be"}, + {"invalid term", func(a *ec2RIPurchaseArgs) { a.TermYears = 2 }, "invalid term_years"}, + {"invalid payment option", func(a *ec2RIPurchaseArgs) { a.PaymentOption = "bogus" }, "invalid payment_option"}, + {"invalid platform", func(a *ec2RIPurchaseArgs) { a.Platform = "MacOS" }, "invalid platform"}, + {"invalid tenancy", func(a *ec2RIPurchaseArgs) { a.Tenancy = "host" }, "invalid tenancy"}, + {"invalid scope", func(a *ec2RIPurchaseArgs) { a.Scope = "zonal" }, "invalid scope"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := validEC2Args() + tc.mutate(&args) + _, _, _, err := ec2RecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +// TestAWSEC2RIPurchaseHandleConfirmFalseRefuses proves the end-to-end tool +// handler refuses a dry_run=false, confirm=false call with a structured +// error, never touching the provider. +func TestAWSEC2RIPurchaseHandleConfirmFalseRefuses(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &awsEC2RIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validEC2Args() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(false) + + _, _, err := tool.handle(context.Background(), nil, args) + require.Error(t, err) + assert.False(t, resolveCalled) + assert.Contains(t, err.Error(), "confirm=true") +} + +// TestAWSEC2RIPurchaseHandleDryRunNeverCallsProvider proves the default +// dry_run=true path never resolves a provider, even with confirm=true. +func TestAWSEC2RIPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &awsEC2RIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validEC2Args() + args.Confirm = boolPtr(true) // dry_run stays at its true default + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.False(t, resolveCalled) + assert.True(t, resp.DryRun) + assert.True(t, resp.Success) +} + +// TestAWSEC2RIPurchaseHandleInvalidArgsNeverCallsProvider proves a boundary +// validation failure (bad enum) short-circuits before any provider call. +func TestAWSEC2RIPurchaseHandleInvalidArgsNeverCallsProvider(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &awsEC2RIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validEC2Args() + args.TermYears = 5 // invalid + + _, _, err := tool.handle(context.Background(), nil, args) + require.Error(t, err) + assert.False(t, resolveCalled) + assert.Contains(t, err.Error(), "invalid term_years") +} + +// TestAWSEC2RIPurchaseHandleRealPurchaseResolvesEC2Client proves a +// confirmed real purchase resolves the AWS EC2 service client for the +// requested region. +func TestAWSEC2RIPurchaseHandleRealPurchaseResolvesEC2Client(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "ri-abc"}} + var gotService common.ServiceType + var gotRegion string + fp := &fakeProvider{ + name: "aws", + } + tool := &awsEC2RIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &recordingProvider{fakeProvider: fp, client: fake, gotService: &gotService, gotRegion: &gotRegion}, nil + }, + } + args := validEC2Args() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, "ri-abc", resp.CommitmentID) + assert.Equal(t, common.ServiceEC2, gotService) + assert.Equal(t, "us-east-1", gotRegion) + assert.Equal(t, 1, fake.purchaseCalls) + assert.Equal(t, common.PurchaseSourceMCP, fake.lastOpts.Source) +} + +// recordingProvider wraps fakeProvider to capture the service/region passed +// to GetServiceClient and always return a fixed ServiceClient. +type recordingProvider struct { + *fakeProvider + client provider.ServiceClient + gotService *common.ServiceType + gotRegion *string +} + +func (r *recordingProvider) GetServiceClient(_ context.Context, service common.ServiceType, region string) (provider.ServiceClient, error) { + *r.gotService = service + *r.gotRegion = region + return r.client, nil +} From f199291b6cb1b519322700efe0d0624a9daae33b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:32:16 +0200 Subject: [PATCH 07/83] feat(mcp): aws opensearch, redshift, memorydb ri purchase tools Adds cudly_aws_opensearch_ri_purchase, cudly_aws_redshift_ri_purchase, and cudly_aws_memorydb_ri_purchase via one generic simpleAWSRIPurchaseTool: none of these three clients read Recommendation.Details (providers/aws/services/{opensearch,redshift, memorydb}/client.go), so they share an identical region+resource_type+count+term+payment_option shape and the same dry_run/confirm gate, differing only in service type and resource-type description. A single test suite runs the shared safety-rail assertions (confirm gate, dry_run gate, boundary validation, real purchase wiring) once per product. --- mcp/server.go | 3 + mcp/tools/aws_simple_ri.go | 199 ++++++++++++++++++++++++++++++++ mcp/tools/aws_simple_ri_test.go | 162 ++++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 mcp/tools/aws_simple_ri.go create mode 100644 mcp/tools/aws_simple_ri_test.go diff --git a/mcp/server.go b/mcp/server.go index e134a1bdc..416b0fe06 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -24,6 +24,9 @@ func registrations() []tools.Registration { return []tools.Registration{ tools.NewSearchRecommendationsTool(), tools.NewAWSEC2RIPurchaseTool(), + tools.NewAWSOpenSearchRIPurchaseTool(), + tools.NewAWSRedshiftRIPurchaseTool(), + tools.NewAWSMemoryDBRIPurchaseTool(), } } diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go new file mode 100644 index 000000000..1cda9428f --- /dev/null +++ b/mcp/tools/aws_simple_ri.go @@ -0,0 +1,199 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +// simpleAWSRIPurchaseSpec configures a region+resource_type+count+term+ +// payment_option AWS RI purchase tool -- the shape shared by OpenSearch, +// Redshift, and MemoryDB. None of their PurchaseCommitment implementations +// read rec.Details (providers/aws/services/{opensearch,redshift,memorydb}/ +// client.go), unlike EC2 (ComputeDetails) or RDS/ElastiCache (Database/ +// CacheDetails), so one generic tool type serves all three rather than +// three near-identical copies. +type simpleAWSRIPurchaseSpec struct { + name string + product string + service common.ServiceType + resourceTypeDesc string // jsonschema description for the resource_type field + examplePrompts []string +} + +// simpleAWSRIPurchaseArgs is the input schema shared by every +// simpleAWSRIPurchaseTool instance. +type simpleAWSRIPurchaseArgs struct { + Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` + ResourceType string `json:"resource_type" jsonschema:"resource/node type to reserve"` + Count int `json:"count" jsonschema:"number of nodes/instances to reserve, must be > 0"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + 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"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` +} + +type simpleAWSRIPurchaseTool struct { + spec simpleAWSRIPurchaseSpec + createProvider func(name string, cfg *provider.ProviderConfig) (provider.Provider, error) +} + +// newSimpleAWSRIPurchaseTool builds a Registration for spec. +func newSimpleAWSRIPurchaseTool(spec simpleAWSRIPurchaseSpec) Registration { + return &simpleAWSRIPurchaseTool{spec: spec, createProvider: provider.CreateProvider} +} + +// NewAWSOpenSearchRIPurchaseTool builds cudly_aws_opensearch_ri_purchase. +func NewAWSOpenSearchRIPurchaseTool() Registration { + return newSimpleAWSRIPurchaseTool(simpleAWSRIPurchaseSpec{ + name: "cudly_aws_opensearch_ri_purchase", + product: "opensearch", + service: common.ServiceOpenSearch, + resourceTypeDesc: "OpenSearch instance type, e.g. r6g.large.search", + examplePrompts: []string{ + "Preview buying 2 r6g.large.search OpenSearch RIs in us-east-1 for 1 year", + "Buy an OpenSearch Reserved Instance in eu-west-1 for real", + }, + }) +} + +// NewAWSRedshiftRIPurchaseTool builds cudly_aws_redshift_ri_purchase. +func NewAWSRedshiftRIPurchaseTool() Registration { + return newSimpleAWSRIPurchaseTool(simpleAWSRIPurchaseSpec{ + name: "cudly_aws_redshift_ri_purchase", + product: "redshift", + service: common.ServiceRedshift, + resourceTypeDesc: "Redshift node type, e.g. dc2.large", + examplePrompts: []string{ + "Preview buying 4 dc2.large Redshift RIs in us-east-1 for 3 years, all-upfront", + }, + }) +} + +// NewAWSMemoryDBRIPurchaseTool builds cudly_aws_memorydb_ri_purchase. +func NewAWSMemoryDBRIPurchaseTool() Registration { + return newSimpleAWSRIPurchaseTool(simpleAWSRIPurchaseSpec{ + name: "cudly_aws_memorydb_ri_purchase", + product: "memorydb", + service: common.ServiceMemoryDB, + resourceTypeDesc: "MemoryDB node type, e.g. db.r6g.large", + examplePrompts: []string{ + "Preview buying 2 db.r6g.large MemoryDB RIs in us-east-1", + }, + }) +} + +func (t *simpleAWSRIPurchaseTool) Descriptor() Descriptor { + return Descriptor{ + Name: t.spec.name, + Provider: "aws", + Product: t.spec.product, + Action: "ri_purchase", + Description: fmt.Sprintf( + "Purchase AWS %s Reserved Instances. THIS SPENDS REAL MONEY when dry_run=false and confirm=true. "+ + "Always call with dry_run=true first (the default) to validate your parameters before "+ + "committing; a dry_run response never contacts AWS and never spends money.", + t.spec.product), + RealPurchaseEnabled: true, + ExamplePrompts: t.spec.examplePrompts, + } +} + +func (t *simpleAWSRIPurchaseTool) Register(s *mcp.Server) error { + desc := t.Descriptor().Description + schema, err := BuildInputSchema[simpleAWSRIPurchaseArgs](map[string]FieldOverride{ + "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, + "payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionPartialUpfront), string(PaymentOptionNoUpfront)}}, + "dry_run": {Default: true}, + "confirm": {Default: false}, + }) + if err != nil { + return err + } + // resource_type's description is spec-specific (differs per product), + // so it is set directly rather than through a generic FieldOverride. + if prop, ok := schema.Properties["resource_type"]; ok { + prop.Description = t.spec.resourceTypeDesc + } + mcp.AddTool(s, &mcp.Tool{ + Name: t.spec.name, + Description: desc, + InputSchema: schema, + }, t.handle) + return nil +} + +func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args simpleAWSRIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { + rec, dryRun, confirm, err := t.recommendationFromArgs(args) + if err != nil { + return nil, PurchaseResponse{}, err + } + + resp, err := ExecutePurchase(ctx, PurchaseRequest{ + Region: args.Region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args), + }) + if err != nil { + return nil, PurchaseResponse{}, err + } + return nil, *resp, nil +} + +func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchaseArgs) (common.Recommendation, bool, bool, error) { + if args.Region == "" { + return common.Recommendation{}, false, false, fmt.Errorf("region is required") + } + if args.ResourceType == "" { + return common.Recommendation{}, false, false, fmt.Errorf("resource_type is required") + } + if args.Count <= 0 { + return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) + } + term, err := ValidateTermYears(args.TermYears) + if err != nil { + return common.Recommendation{}, false, false, err + } + paymentOption, err := ValidatePaymentOption(args.PaymentOption) + if err != nil { + return common.Recommendation{}, false, false, err + } + + rec := common.Recommendation{ + Provider: common.ProviderAWS, + Service: t.spec.service, + Region: args.Region, + ResourceType: args.ResourceType, + Count: args.Count, + CommitmentType: common.CommitmentReservedInstance, + Term: term.RecommendationTerm(), + PaymentOption: string(paymentOption), + } + + dryRun, confirm := true, false + if args.DryRun != nil { + dryRun = *args.DryRun + } + if args.Confirm != nil { + confirm = *args.Confirm + } + return rec, dryRun, confirm, nil +} + +func (t *simpleAWSRIPurchaseTool) resolveClient(args simpleAWSRIPurchaseArgs) ResolveClientFunc { + return func(ctx context.Context) (provider.ServiceClient, error) { + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: args.Region} + prov, err := t.createProvider(string(common.ProviderAWS), cfg) + if err != nil { + return nil, err + } + return prov.GetServiceClient(ctx, t.spec.service, args.Region) + } +} diff --git a/mcp/tools/aws_simple_ri_test.go b/mcp/tools/aws_simple_ri_test.go new file mode 100644 index 000000000..7432e6e47 --- /dev/null +++ b/mcp/tools/aws_simple_ri_test.go @@ -0,0 +1,162 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +// simpleToolConstructors covers every simpleAWSRIPurchaseTool instance so +// the shared safety-rail behaviour (confirm gate, dry_run gate, boundary +// validation, real-purchase wiring) is proven once per product rather than +// hand-copied three times. +func simpleToolConstructors() map[string]func() Registration { + return map[string]func() Registration{ + "opensearch": NewAWSOpenSearchRIPurchaseTool, + "redshift": NewAWSRedshiftRIPurchaseTool, + "memorydb": NewAWSMemoryDBRIPurchaseTool, + } +} + +func validSimpleArgs() simpleAWSRIPurchaseArgs { + return simpleAWSRIPurchaseArgs{ + Region: "us-east-1", + ResourceType: "r6g.large", + Count: 2, + TermYears: 1, + PaymentOption: "all-upfront", + } +} + +func TestSimpleAWSRIPurchaseDescriptorsAreDistinctAndRealPurchaseEnabled(t *testing.T) { + t.Parallel() + names := map[string]bool{} + for product, ctor := range simpleToolConstructors() { + d := ctor().Descriptor() + assert.True(t, d.RealPurchaseEnabled, "%s must be real-purchase enabled", product) + assert.False(t, names[d.Name], "duplicate tool name %q", d.Name) + names[d.Name] = true + assert.NotEmpty(t, d.ExamplePrompts, "%s must document example prompts", product) + } +} + +func TestSimpleAWSRIPurchaseRecommendationFromArgs(t *testing.T) { + t.Parallel() + for product, ctor := range simpleToolConstructors() { + t.Run(product, func(t *testing.T) { + tool := ctor().(*simpleAWSRIPurchaseTool) + rec, dryRun, confirm, err := tool.recommendationFromArgs(validSimpleArgs()) + require.NoError(t, err) + assert.True(t, dryRun) + assert.False(t, confirm) + assert.Equal(t, common.ProviderAWS, rec.Provider) + assert.Equal(t, tool.spec.service, rec.Service) + assert.Equal(t, "r6g.large", rec.ResourceType) + assert.Equal(t, 2, rec.Count) + assert.Equal(t, "1yr", rec.Term) + assert.Equal(t, "all-upfront", rec.PaymentOption) + assert.Nil(t, rec.Details, "%s must not require service Details", product) + }) + } +} + +func TestSimpleAWSRIPurchaseInvalidArgs(t *testing.T) { + t.Parallel() + tool := NewAWSOpenSearchRIPurchaseTool().(*simpleAWSRIPurchaseTool) + cases := []struct { + name string + mutate func(*simpleAWSRIPurchaseArgs) + errSub string + }{ + {"missing region", func(a *simpleAWSRIPurchaseArgs) { a.Region = "" }, "region is required"}, + {"missing resource_type", func(a *simpleAWSRIPurchaseArgs) { a.ResourceType = "" }, "resource_type is required"}, + {"zero count", func(a *simpleAWSRIPurchaseArgs) { a.Count = 0 }, "count must be"}, + {"invalid term", func(a *simpleAWSRIPurchaseArgs) { a.TermYears = 4 }, "invalid term_years"}, + {"invalid payment option", func(a *simpleAWSRIPurchaseArgs) { a.PaymentOption = "bogus" }, "invalid payment_option"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := validSimpleArgs() + tc.mutate(&args) + _, _, _, err := tool.recommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +func TestSimpleAWSRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { + t.Parallel() + for product, ctor := range simpleToolConstructors() { + t.Run(product, func(t *testing.T) { + tool := ctor().(*simpleAWSRIPurchaseTool) + resolveCalled := false + tool.createProvider = func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + } + args := validSimpleArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(false) + + _, _, err := tool.handle(context.Background(), nil, args) + require.Error(t, err) + assert.False(t, resolveCalled) + assert.Contains(t, err.Error(), "confirm=true") + }) + } +} + +func TestSimpleAWSRIPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { + t.Parallel() + for product, ctor := range simpleToolConstructors() { + t.Run(product, func(t *testing.T) { + tool := ctor().(*simpleAWSRIPurchaseTool) + resolveCalled := false + tool.createProvider = func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + } + args := validSimpleArgs() + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.False(t, resolveCalled) + assert.True(t, resp.DryRun) + }) + } +} + +func TestSimpleAWSRIPurchaseHandleRealPurchaseCallsCorrectService(t *testing.T) { + t.Parallel() + for product, ctor := range simpleToolConstructors() { + t.Run(product, func(t *testing.T) { + tool := ctor().(*simpleAWSRIPurchaseTool) + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "res-1"}} + var gotService common.ServiceType + tool.createProvider = func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "aws"}, + client: fake, + gotService: &gotService, + gotRegion: new(string), + }, nil + } + args := validSimpleArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, tool.spec.service, gotService) + assert.Equal(t, common.PurchaseSourceMCP, fake.lastOpts.Source) + }) + } +} From 62f9a751417c50a727d345e4d864f634eb4e527f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:35:23 +0200 Subject: [PATCH 08/83] feat(mcp): aws rds and elasticache ri purchase tools Adds cudly_aws_rds_ri_purchase and cudly_aws_elasticache_ri_purchase. Both require a Recommendation.Details value their client's offering lookup reads: RDS needs DatabaseDetails{Engine, AZConfig} (az_config has no safe default -- providers/aws/services/rds/client.go refuses to guess single-az vs multi-az since they have different prices and don't cover each other's demand), and ElastiCache needs CacheDetails{Engine} validated against the new CacheEngine enum (redis/memcached). --- mcp/server.go | 2 + mcp/tools/aws_elasticache_ri.go | 164 +++++++++++++++++++++++++ mcp/tools/aws_elasticache_ri_test.go | 124 +++++++++++++++++++ mcp/tools/aws_rds_ri.go | 171 +++++++++++++++++++++++++++ mcp/tools/aws_rds_ri_test.go | 127 ++++++++++++++++++++ mcp/tools/enums.go | 19 +++ mcp/tools/enums_test.go | 26 ++++ 7 files changed, 633 insertions(+) create mode 100644 mcp/tools/aws_elasticache_ri.go create mode 100644 mcp/tools/aws_elasticache_ri_test.go create mode 100644 mcp/tools/aws_rds_ri.go create mode 100644 mcp/tools/aws_rds_ri_test.go diff --git a/mcp/server.go b/mcp/server.go index 416b0fe06..05bc8af65 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -27,6 +27,8 @@ func registrations() []tools.Registration { tools.NewAWSOpenSearchRIPurchaseTool(), tools.NewAWSRedshiftRIPurchaseTool(), tools.NewAWSMemoryDBRIPurchaseTool(), + tools.NewAWSRDSRIPurchaseTool(), + tools.NewAWSElastiCacheRIPurchaseTool(), } } diff --git a/mcp/tools/aws_elasticache_ri.go b/mcp/tools/aws_elasticache_ri.go new file mode 100644 index 000000000..7ff78dce1 --- /dev/null +++ b/mcp/tools/aws_elasticache_ri.go @@ -0,0 +1,164 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +const awsElastiCacheRIPurchaseName = "cudly_aws_elasticache_ri_purchase" + +const awsElastiCacheRIPurchaseDescription = "Purchase AWS ElastiCache Reserved Cache Nodes. THIS SPENDS REAL " + + "MONEY when dry_run=false and confirm=true. Always call with dry_run=true first (the default) to validate " + + "your parameters before committing; a dry_run response never contacts AWS and never spends money." + +// elasticacheRIPurchaseArgs is the input schema for +// cudly_aws_elasticache_ri_purchase. engine maps onto common.CacheDetails, +// which providers/aws/services/elasticache/client.go:273-283 requires for +// the offering lookup. +type elasticacheRIPurchaseArgs struct { + Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` + NodeType string `json:"node_type" jsonschema:"ElastiCache cache node type, e.g. cache.r6g.large"` + Count int `json:"count" jsonschema:"number of cache nodes to reserve, must be > 0"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` + Engine string `json:"engine" jsonschema:"cache engine"` + AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` +} + +type awsElastiCacheRIPurchaseTool struct { + createProvider func(name string, cfg *provider.ProviderConfig) (provider.Provider, error) +} + +// NewAWSElastiCacheRIPurchaseTool builds the cudly_aws_elasticache_ri_purchase tool. +func NewAWSElastiCacheRIPurchaseTool() Registration { + return &awsElastiCacheRIPurchaseTool{createProvider: provider.CreateProvider} +} + +func (t *awsElastiCacheRIPurchaseTool) Descriptor() Descriptor { + return Descriptor{ + Name: awsElastiCacheRIPurchaseName, + Provider: "aws", + Product: "elasticache", + Action: "ri_purchase", + Description: awsElastiCacheRIPurchaseDescription, + RealPurchaseEnabled: true, + ExamplePrompts: []string{ + "Preview buying 3 cache.r6g.large redis ElastiCache RIs in us-east-1 for 1 year", + "Buy an ElastiCache Reserved Cache Node for memcached in eu-west-1 for real", + }, + } +} + +func (t *awsElastiCacheRIPurchaseTool) Register(s *mcp.Server) error { + schema, err := BuildInputSchema[elasticacheRIPurchaseArgs](map[string]FieldOverride{ + "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, + "payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionPartialUpfront), string(PaymentOptionNoUpfront)}}, + "engine": {Enum: []any{string(CacheEngineRedis), string(CacheEngineMemcached)}}, + "dry_run": {Default: true}, + "confirm": {Default: false}, + }) + if err != nil { + return err + } + mcp.AddTool(s, &mcp.Tool{ + Name: awsElastiCacheRIPurchaseName, + Description: awsElastiCacheRIPurchaseDescription, + InputSchema: schema, + }, t.handle) + return nil +} + +func (t *awsElastiCacheRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args elasticacheRIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { + rec, dryRun, confirm, err := elasticacheRecommendationFromArgs(args) + if err != nil { + return nil, PurchaseResponse{}, err + } + + resp, err := ExecutePurchase(ctx, PurchaseRequest{ + Region: args.Region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args), + }) + if err != nil { + return nil, PurchaseResponse{}, err + } + return nil, *resp, nil +} + +func elasticacheRIPurchaseRequiredFields(args elasticacheRIPurchaseArgs) error { + if args.Region == "" { + return fmt.Errorf("region is required") + } + if args.NodeType == "" { + return fmt.Errorf("node_type is required") + } + if args.Count <= 0 { + return fmt.Errorf("count must be > 0, got %d", args.Count) + } + return nil +} + +// elasticacheRecommendationFromArgs validates args and builds the +// common.Recommendation to purchase, plus the effective dry_run/confirm +// booleans. +func elasticacheRecommendationFromArgs(args elasticacheRIPurchaseArgs) (common.Recommendation, bool, bool, error) { + if err := elasticacheRIPurchaseRequiredFields(args); err != nil { + return common.Recommendation{}, false, false, err + } + term, err := ValidateTermYears(args.TermYears) + if err != nil { + return common.Recommendation{}, false, false, err + } + paymentOption, err := ValidatePaymentOption(args.PaymentOption) + if err != nil { + return common.Recommendation{}, false, false, err + } + engine, err := ValidateCacheEngine(args.Engine) + if err != nil { + return common.Recommendation{}, false, false, err + } + + rec := common.Recommendation{ + Provider: common.ProviderAWS, + Service: common.ServiceElastiCache, + Region: args.Region, + ResourceType: args.NodeType, + Count: args.Count, + CommitmentType: common.CommitmentReservedInstance, + Term: term.RecommendationTerm(), + PaymentOption: string(paymentOption), + Details: &common.CacheDetails{ + Engine: string(engine), + NodeType: args.NodeType, + }, + } + + dryRun, confirm := true, false + if args.DryRun != nil { + dryRun = *args.DryRun + } + if args.Confirm != nil { + confirm = *args.Confirm + } + return rec, dryRun, confirm, nil +} + +func (t *awsElastiCacheRIPurchaseTool) resolveClient(args elasticacheRIPurchaseArgs) ResolveClientFunc { + return func(ctx context.Context) (provider.ServiceClient, error) { + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: args.Region} + prov, err := t.createProvider(string(common.ProviderAWS), cfg) + if err != nil { + return nil, err + } + return prov.GetServiceClient(ctx, common.ServiceElastiCache, args.Region) + } +} diff --git a/mcp/tools/aws_elasticache_ri_test.go b/mcp/tools/aws_elasticache_ri_test.go new file mode 100644 index 000000000..891a59000 --- /dev/null +++ b/mcp/tools/aws_elasticache_ri_test.go @@ -0,0 +1,124 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +func validElastiCacheArgs() elasticacheRIPurchaseArgs { + return elasticacheRIPurchaseArgs{ + Region: "us-east-1", + NodeType: "cache.r6g.large", + Count: 3, + TermYears: 1, + PaymentOption: "all-upfront", + Engine: "redis", + } +} + +func TestElastiCacheRecommendationFromArgs(t *testing.T) { + t.Parallel() + rec, dryRun, confirm, err := elasticacheRecommendationFromArgs(validElastiCacheArgs()) + require.NoError(t, err) + assert.True(t, dryRun) + assert.False(t, confirm) + assert.Equal(t, common.ServiceElastiCache, rec.Service) + details, ok := rec.Details.(*common.CacheDetails) + require.True(t, ok) + assert.Equal(t, "redis", details.Engine) + assert.Equal(t, "cache.r6g.large", details.NodeType) +} + +func TestElastiCacheRecommendationFromArgsInvalid(t *testing.T) { + t.Parallel() + cases := []struct { + name string + mutate func(*elasticacheRIPurchaseArgs) + errSub string + }{ + {"missing region", func(a *elasticacheRIPurchaseArgs) { a.Region = "" }, "region is required"}, + {"missing node_type", func(a *elasticacheRIPurchaseArgs) { a.NodeType = "" }, "node_type is required"}, + {"zero count", func(a *elasticacheRIPurchaseArgs) { a.Count = 0 }, "count must be"}, + {"invalid term", func(a *elasticacheRIPurchaseArgs) { a.TermYears = 5 }, "invalid term_years"}, + {"invalid payment option", func(a *elasticacheRIPurchaseArgs) { a.PaymentOption = "bogus" }, "invalid payment_option"}, + {"missing engine", func(a *elasticacheRIPurchaseArgs) { a.Engine = "" }, "invalid engine"}, + {"invalid engine", func(a *elasticacheRIPurchaseArgs) { a.Engine = "postgres" }, "invalid engine"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := validElastiCacheArgs() + tc.mutate(&args) + _, _, _, err := elasticacheRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +func TestAWSElastiCacheRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &awsElastiCacheRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validElastiCacheArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(false) + + _, _, err := tool.handle(context.Background(), nil, args) + require.Error(t, err) + assert.False(t, resolveCalled) + assert.Contains(t, err.Error(), "confirm=true") +} + +func TestAWSElastiCacheRIPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &awsElastiCacheRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validElastiCacheArgs() + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.False(t, resolveCalled) + assert.True(t, resp.DryRun) +} + +func TestAWSElastiCacheRIPurchaseHandleRealPurchase(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "ec-ri-1"}} + var gotService common.ServiceType + tool := &awsElastiCacheRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "aws"}, + client: fake, + gotService: &gotService, + gotRegion: new(string), + }, nil + }, + } + args := validElastiCacheArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, common.ServiceElastiCache, gotService) + assert.Equal(t, common.PurchaseSourceMCP, fake.lastOpts.Source) +} diff --git a/mcp/tools/aws_rds_ri.go b/mcp/tools/aws_rds_ri.go new file mode 100644 index 000000000..51ce0e72d --- /dev/null +++ b/mcp/tools/aws_rds_ri.go @@ -0,0 +1,171 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +const awsRDSRIPurchaseName = "cudly_aws_rds_ri_purchase" + +const awsRDSRIPurchaseDescription = "Purchase AWS RDS Reserved Instances. THIS SPENDS REAL MONEY when " + + "dry_run=false and confirm=true. Always call with dry_run=true first (the default) to validate your " + + "parameters before committing; a dry_run response never contacts AWS and never spends money." + +// rdsRIPurchaseArgs is the input schema for cudly_aws_rds_ri_purchase. +// engine and az_config map onto common.DatabaseDetails, which +// providers/aws/services/rds/client.go:301-322 requires -- az_config in +// particular has no safe default (single-AZ and multi-AZ RIs have different +// prices and do not cover each other's demand), so unlike EC2's +// platform/tenancy/scope it is a required field here, not a defaulted one. +type rdsRIPurchaseArgs struct { + Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` + InstanceClass string `json:"instance_class" jsonschema:"RDS DB instance class, e.g. db.r6g.large"` + Count int `json:"count" jsonschema:"number of instances to reserve, must be > 0"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` + Engine string `json:"engine" jsonschema:"RDS database engine, e.g. mysql, postgres, mariadb, oracle-se2, sqlserver-ee"` + 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"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` +} + +type awsRDSRIPurchaseTool struct { + createProvider func(name string, cfg *provider.ProviderConfig) (provider.Provider, error) +} + +// NewAWSRDSRIPurchaseTool builds the cudly_aws_rds_ri_purchase tool. +func NewAWSRDSRIPurchaseTool() Registration { + return &awsRDSRIPurchaseTool{createProvider: provider.CreateProvider} +} + +func (t *awsRDSRIPurchaseTool) Descriptor() Descriptor { + return Descriptor{ + Name: awsRDSRIPurchaseName, + Provider: "aws", + Product: "rds", + Action: "ri_purchase", + Description: awsRDSRIPurchaseDescription, + RealPurchaseEnabled: true, + ExamplePrompts: []string{ + "Preview buying 2 db.r6g.large multi-az postgres RDS RIs in us-east-1 for 3 years", + "Buy an RDS Reserved Instance for a single-az mysql db.t3.medium in eu-west-1 for real", + }, + } +} + +func (t *awsRDSRIPurchaseTool) Register(s *mcp.Server) error { + schema, err := BuildInputSchema[rdsRIPurchaseArgs](map[string]FieldOverride{ + "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, + "payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionPartialUpfront), string(PaymentOptionNoUpfront)}}, + "az_config": {Enum: []any{string(AZConfigSingleAZ), string(AZConfigMultiAZ)}}, + "dry_run": {Default: true}, + "confirm": {Default: false}, + }) + if err != nil { + return err + } + mcp.AddTool(s, &mcp.Tool{ + Name: awsRDSRIPurchaseName, + Description: awsRDSRIPurchaseDescription, + InputSchema: schema, + }, t.handle) + return nil +} + +func (t *awsRDSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args rdsRIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { + rec, dryRun, confirm, err := rdsRecommendationFromArgs(args) + if err != nil { + return nil, PurchaseResponse{}, err + } + + resp, err := ExecutePurchase(ctx, PurchaseRequest{ + Region: args.Region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args), + }) + if err != nil { + return nil, PurchaseResponse{}, err + } + return nil, *resp, nil +} + +func rdsRIPurchaseRequiredFields(args rdsRIPurchaseArgs) error { + if args.Region == "" { + return fmt.Errorf("region is required") + } + if args.InstanceClass == "" { + return fmt.Errorf("instance_class is required") + } + if args.Count <= 0 { + return fmt.Errorf("count must be > 0, got %d", args.Count) + } + if args.Engine == "" { + return fmt.Errorf("engine is required") + } + return nil +} + +// rdsRecommendationFromArgs validates args and builds the +// common.Recommendation to purchase, plus the effective dry_run/confirm +// booleans. +func rdsRecommendationFromArgs(args rdsRIPurchaseArgs) (common.Recommendation, bool, bool, error) { + if err := rdsRIPurchaseRequiredFields(args); err != nil { + return common.Recommendation{}, false, false, err + } + term, err := ValidateTermYears(args.TermYears) + if err != nil { + return common.Recommendation{}, false, false, err + } + paymentOption, err := ValidatePaymentOption(args.PaymentOption) + if err != nil { + return common.Recommendation{}, false, false, err + } + azConfig, err := ValidateAZConfig(args.AZConfig) + if err != nil { + return common.Recommendation{}, false, false, err + } + + rec := common.Recommendation{ + Provider: common.ProviderAWS, + Service: common.ServiceRDS, + Region: args.Region, + ResourceType: args.InstanceClass, + Count: args.Count, + CommitmentType: common.CommitmentReservedInstance, + Term: term.RecommendationTerm(), + PaymentOption: string(paymentOption), + Details: &common.DatabaseDetails{ + Engine: args.Engine, + AZConfig: string(azConfig), + InstanceClass: args.InstanceClass, + }, + } + + dryRun, confirm := true, false + if args.DryRun != nil { + dryRun = *args.DryRun + } + if args.Confirm != nil { + confirm = *args.Confirm + } + return rec, dryRun, confirm, nil +} + +func (t *awsRDSRIPurchaseTool) resolveClient(args rdsRIPurchaseArgs) ResolveClientFunc { + return func(ctx context.Context) (provider.ServiceClient, error) { + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: args.Region} + prov, err := t.createProvider(string(common.ProviderAWS), cfg) + if err != nil { + return nil, err + } + return prov.GetServiceClient(ctx, common.ServiceRDS, args.Region) + } +} diff --git a/mcp/tools/aws_rds_ri_test.go b/mcp/tools/aws_rds_ri_test.go new file mode 100644 index 000000000..2a4f55766 --- /dev/null +++ b/mcp/tools/aws_rds_ri_test.go @@ -0,0 +1,127 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +func validRDSArgs() rdsRIPurchaseArgs { + return rdsRIPurchaseArgs{ + Region: "us-east-1", + InstanceClass: "db.r6g.large", + Count: 2, + TermYears: 3, + PaymentOption: "no-upfront", + Engine: "postgres", + AZConfig: "multi-az", + } +} + +func TestRDSRecommendationFromArgs(t *testing.T) { + t.Parallel() + rec, dryRun, confirm, err := rdsRecommendationFromArgs(validRDSArgs()) + require.NoError(t, err) + assert.True(t, dryRun) + assert.False(t, confirm) + assert.Equal(t, common.ServiceRDS, rec.Service) + details, ok := rec.Details.(*common.DatabaseDetails) + require.True(t, ok) + assert.Equal(t, "postgres", details.Engine) + assert.Equal(t, "multi-az", details.AZConfig) + assert.Equal(t, "db.r6g.large", details.InstanceClass) +} + +func TestRDSRecommendationFromArgsInvalid(t *testing.T) { + t.Parallel() + cases := []struct { + name string + mutate func(*rdsRIPurchaseArgs) + errSub string + }{ + {"missing region", func(a *rdsRIPurchaseArgs) { a.Region = "" }, "region is required"}, + {"missing instance_class", func(a *rdsRIPurchaseArgs) { a.InstanceClass = "" }, "instance_class is required"}, + {"missing engine", func(a *rdsRIPurchaseArgs) { a.Engine = "" }, "engine is required"}, + {"zero count", func(a *rdsRIPurchaseArgs) { a.Count = 0 }, "count must be"}, + {"invalid term", func(a *rdsRIPurchaseArgs) { a.TermYears = 2 }, "invalid term_years"}, + {"invalid payment option", func(a *rdsRIPurchaseArgs) { a.PaymentOption = "bogus" }, "invalid payment_option"}, + {"missing az_config refuses to guess", func(a *rdsRIPurchaseArgs) { a.AZConfig = "" }, "invalid az_config"}, + {"invalid az_config", func(a *rdsRIPurchaseArgs) { a.AZConfig = "triple-az" }, "invalid az_config"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := validRDSArgs() + tc.mutate(&args) + _, _, _, err := rdsRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +func TestAWSRDSRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &awsRDSRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validRDSArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(false) + + _, _, err := tool.handle(context.Background(), nil, args) + require.Error(t, err) + assert.False(t, resolveCalled) + assert.Contains(t, err.Error(), "confirm=true") +} + +func TestAWSRDSRIPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &awsRDSRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validRDSArgs() + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.False(t, resolveCalled) + assert.True(t, resp.DryRun) +} + +func TestAWSRDSRIPurchaseHandleRealPurchase(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "rds-ri-1"}} + var gotService common.ServiceType + tool := &awsRDSRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "aws"}, + client: fake, + gotService: &gotService, + gotRegion: new(string), + }, nil + }, + } + args := validRDSArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, common.ServiceRDS, gotService) + assert.Equal(t, common.PurchaseSourceMCP, fake.lastOpts.Source) +} diff --git a/mcp/tools/enums.go b/mcp/tools/enums.go index e475aaec8..ac614ca1e 100644 --- a/mcp/tools/enums.go +++ b/mcp/tools/enums.go @@ -173,3 +173,22 @@ func ValidateScope(s string) (Scope, error) { return "", fmt.Errorf("invalid scope %q: must be %s or %s", s, ScopeRegion, ScopeAvailabilityZone) } } + +// CacheEngine is the ElastiCache engine dimension (common.CacheDetails.Engine). +type CacheEngine string + +const ( + CacheEngineRedis CacheEngine = "redis" + CacheEngineMemcached CacheEngine = "memcached" +) + +// ValidateCacheEngine returns the typed CacheEngine for s, or an explicit +// error when s is neither redis nor memcached. +func ValidateCacheEngine(s string) (CacheEngine, error) { + switch CacheEngine(s) { + case CacheEngineRedis, CacheEngineMemcached: + return CacheEngine(s), nil + default: + return "", fmt.Errorf("invalid engine %q: must be %s or %s", s, CacheEngineRedis, CacheEngineMemcached) + } +} diff --git a/mcp/tools/enums_test.go b/mcp/tools/enums_test.go index 763fc33a6..234d485e3 100644 --- a/mcp/tools/enums_test.go +++ b/mcp/tools/enums_test.go @@ -178,6 +178,32 @@ func TestValidateTenancy(t *testing.T) { } } +func TestValidateCacheEngine(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want CacheEngine + wantErr bool + }{ + {"redis", "redis", CacheEngineRedis, false}, + {"memcached", "memcached", CacheEngineMemcached, false}, + {"empty", "", "", true}, + {"unknown", "postgres", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidateCacheEngine(tc.in) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + func TestValidateScope(t *testing.T) { t.Parallel() cases := []struct { From a75421a02c59e126ac2fc7dac26daf8f8a371e2d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:39:10 +0200 Subject: [PATCH 09/83] feat(mcp): aws savings plans purchase tool Adds cudly_aws_savingsplans_purchase, dollar-denominated rather than count-based (hourly_commitment, USD/hour). sp_type resolves to the precise per-plan-type ServiceType (e.g. ServiceSavingsPlansCompute) via the existing ServiceTypeForPlanType helper rather than the ServiceSavingsPlansAll umbrella sentinel, so the resolved client's own resolveSPPlanType cross-check rejects a mismatched Details.PlanType as defense in depth on top of the ValidateSPType boundary check. EC2Instance plans require region (they are region-scoped); Compute, SageMaker, and Database plans are account-level and default to the same us-east-1 single-query convention cmd/multi_service_helpers.go already uses for account-level Savings Plans recommendations. --- mcp/server.go | 1 + mcp/tools/aws_savingsplans.go | 181 +++++++++++++++++++++++++++++ mcp/tools/aws_savingsplans_test.go | 144 +++++++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 mcp/tools/aws_savingsplans.go create mode 100644 mcp/tools/aws_savingsplans_test.go diff --git a/mcp/server.go b/mcp/server.go index 05bc8af65..1ed727245 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -29,6 +29,7 @@ func registrations() []tools.Registration { tools.NewAWSMemoryDBRIPurchaseTool(), tools.NewAWSRDSRIPurchaseTool(), tools.NewAWSElastiCacheRIPurchaseTool(), + tools.NewAWSSavingsPlansPurchaseTool(), } } diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go new file mode 100644 index 000000000..0e85f4916 --- /dev/null +++ b/mcp/tools/aws_savingsplans.go @@ -0,0 +1,181 @@ +package tools + +import ( + "context" + "fmt" + + spTypes "github.com/aws/aws-sdk-go-v2/service/savingsplans/types" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" + "github.com/LeanerCloud/CUDly/providers/aws/services/savingsplans" +) + +const awsSavingsPlansPurchaseName = "cudly_aws_savingsplans_purchase" + +const awsSavingsPlansPurchaseDescription = "Purchase an AWS Savings Plan (Compute, EC2Instance, SageMaker, or " + + "Database). THIS SPENDS REAL MONEY when dry_run=false and confirm=true. Always call with dry_run=true " + + "first (the default) to validate your parameters before committing; a dry_run response never contacts AWS " + + "and never spends money. Unlike RI purchases this is dollar-denominated: you specify hourly_commitment " + + "(USD/hour), not an instance count." + +// 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. +const savingsPlansAccountLevelRegion = "us-east-1" + +// savingsPlansPurchaseArgs is the input schema for +// cudly_aws_savingsplans_purchase. instance_family and region are only +// meaningful for EC2Instance plans (common.SavingsPlanDetails); Compute, +// SageMaker, and Database plans are family-agnostic and account-level. +type savingsPlansPurchaseArgs struct { + SPType string `json:"sp_type" jsonschema:"AWS Savings Plans type"` + HourlyCommitment float64 `json:"hourly_commitment" jsonschema:"USD/hour commitment amount, must be > 0"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` + InstanceFamily string `json:"instance_family,omitempty" jsonschema:"EC2 instance family, e.g. m5; only meaningful for sp_type=EC2Instance"` + 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"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` +} + +type awsSavingsPlansPurchaseTool struct { + createProvider func(name string, cfg *provider.ProviderConfig) (provider.Provider, error) +} + +// NewAWSSavingsPlansPurchaseTool builds the cudly_aws_savingsplans_purchase tool. +func NewAWSSavingsPlansPurchaseTool() Registration { + return &awsSavingsPlansPurchaseTool{createProvider: provider.CreateProvider} +} + +func (t *awsSavingsPlansPurchaseTool) Descriptor() Descriptor { + return Descriptor{ + Name: awsSavingsPlansPurchaseName, + Provider: "aws", + Product: "savingsplans", + Action: "purchase", + Description: awsSavingsPlansPurchaseDescription, + RealPurchaseEnabled: true, + ExamplePrompts: []string{ + "Preview a $10/hour Compute Savings Plan, 3-year no-upfront", + "Buy a $5/hour EC2Instance Savings Plan for the m5 family in us-east-1 for real", + }, + } +} + +func (t *awsSavingsPlansPurchaseTool) Register(s *mcp.Server) error { + schema, err := BuildInputSchema[savingsPlansPurchaseArgs](map[string]FieldOverride{ + "sp_type": {Enum: []any{ + string(SPTypeCompute), string(SPTypeEC2Instance), string(SPTypeSageMaker), string(SPTypeDatabase), + }}, + "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, + "payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionPartialUpfront), string(PaymentOptionNoUpfront)}}, + "dry_run": {Default: true}, + "confirm": {Default: false}, + }) + if err != nil { + return err + } + mcp.AddTool(s, &mcp.Tool{ + Name: awsSavingsPlansPurchaseName, + Description: awsSavingsPlansPurchaseDescription, + InputSchema: schema, + }, t.handle) + return nil +} + +func (t *awsSavingsPlansPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args savingsPlansPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { + rec, region, dryRun, confirm, err := savingsPlanRecommendationFromArgs(args) + if err != nil { + return nil, PurchaseResponse{}, err + } + + resp, err := ExecutePurchase(ctx, PurchaseRequest{ + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args, region, rec.Service), + }) + if err != nil { + return nil, PurchaseResponse{}, err + } + return nil, *resp, nil +} + +// savingsPlanRecommendationFromArgs validates args and builds the +// common.Recommendation to purchase, the effective region to resolve the +// service client against, and the effective dry_run/confirm booleans. +func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (common.Recommendation, string, bool, bool, error) { + if args.HourlyCommitment <= 0 { + return common.Recommendation{}, "", false, false, fmt.Errorf("hourly_commitment must be > 0, got %v", args.HourlyCommitment) + } + spType, err := ValidateSPType(args.SPType) + if err != nil { + return common.Recommendation{}, "", false, false, err + } + term, err := ValidateTermYears(args.TermYears) + if err != nil { + return common.Recommendation{}, "", false, false, err + } + paymentOption, err := ValidatePaymentOption(args.PaymentOption) + if err != nil { + return common.Recommendation{}, "", false, false, err + } + if spType == SPTypeEC2Instance && args.Region == "" { + return common.Recommendation{}, "", false, false, fmt.Errorf("region is required for sp_type=%s", SPTypeEC2Instance) + } + + region := args.Region + if region == "" { + region = savingsPlansAccountLevelRegion + } + + // Resolve the precise per-plan-type ServiceType (e.g. + // ServiceSavingsPlansCompute) rather than the ServiceSavingsPlansAll + // umbrella sentinel, so GetServiceClient returns a client scoped to + // spType: providers/aws/services/savingsplans/client.go's + // resolveSPPlanType then rejects a mismatched Details.PlanType instead of + // silently buying whatever plan type happens to be in Details (defense in + // depth on top of the ValidateSPType check above). + service := savingsplans.ServiceTypeForPlanType(spTypes.SavingsPlanType(spType)) + + rec := common.Recommendation{ + Provider: common.ProviderAWS, + Service: service, + Region: region, + CommitmentType: common.CommitmentSavingsPlan, + Term: term.RecommendationTerm(), + PaymentOption: string(paymentOption), + Details: &common.SavingsPlanDetails{ + PlanType: string(spType), + HourlyCommitment: args.HourlyCommitment, + InstanceFamily: args.InstanceFamily, + Region: args.Region, + }, + } + + dryRun, confirm := true, false + if args.DryRun != nil { + dryRun = *args.DryRun + } + if args.Confirm != nil { + confirm = *args.Confirm + } + return rec, region, dryRun, confirm, nil +} + +func (t *awsSavingsPlansPurchaseTool) resolveClient(args savingsPlansPurchaseArgs, region string, service common.ServiceType) ResolveClientFunc { + return func(ctx context.Context) (provider.ServiceClient, error) { + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} + prov, err := t.createProvider(string(common.ProviderAWS), cfg) + if err != nil { + return nil, err + } + return prov.GetServiceClient(ctx, service, region) + } +} diff --git a/mcp/tools/aws_savingsplans_test.go b/mcp/tools/aws_savingsplans_test.go new file mode 100644 index 000000000..acf45be77 --- /dev/null +++ b/mcp/tools/aws_savingsplans_test.go @@ -0,0 +1,144 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +func validSavingsPlansArgs() savingsPlansPurchaseArgs { + return savingsPlansPurchaseArgs{ + SPType: "Compute", + HourlyCommitment: 10.50, + TermYears: 3, + PaymentOption: "no-upfront", + } +} + +func TestSavingsPlanRecommendationFromArgsAccountLevel(t *testing.T) { + t.Parallel() + rec, region, dryRun, confirm, err := savingsPlanRecommendationFromArgs(validSavingsPlansArgs()) + require.NoError(t, err) + assert.True(t, dryRun) + assert.False(t, confirm) + assert.Equal(t, savingsPlansAccountLevelRegion, region, "account-level plan defaults to the shared query region") + assert.Equal(t, common.ServiceSavingsPlansCompute, rec.Service) + assert.Equal(t, common.CommitmentSavingsPlan, rec.CommitmentType) + assert.Equal(t, "3yr", rec.Term) + details, ok := rec.Details.(*common.SavingsPlanDetails) + require.True(t, ok) + assert.Equal(t, "Compute", details.PlanType) + assert.InDelta(t, 10.50, details.HourlyCommitment, 0.001) +} + +func TestSavingsPlanRecommendationFromArgsEC2InstanceRequiresRegion(t *testing.T) { + t.Parallel() + args := validSavingsPlansArgs() + args.SPType = "EC2Instance" + args.InstanceFamily = "m5" + + _, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), "region is required") + + args.Region = "us-east-1" + rec, region, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "us-east-1", region) + assert.Equal(t, common.ServiceSavingsPlansEC2Instance, rec.Service) + details, ok := rec.Details.(*common.SavingsPlanDetails) + require.True(t, ok) + assert.Equal(t, "m5", details.InstanceFamily) + assert.Equal(t, "us-east-1", details.Region) +} + +func TestSavingsPlanRecommendationFromArgsInvalid(t *testing.T) { + t.Parallel() + cases := []struct { + name string + mutate func(*savingsPlansPurchaseArgs) + errSub string + }{ + {"zero hourly commitment", func(a *savingsPlansPurchaseArgs) { a.HourlyCommitment = 0 }, "hourly_commitment must be"}, + {"negative hourly commitment", func(a *savingsPlansPurchaseArgs) { a.HourlyCommitment = -5 }, "hourly_commitment must be"}, + {"invalid sp_type", func(a *savingsPlansPurchaseArgs) { a.SPType = "Storage" }, "invalid sp_type"}, + {"invalid term", func(a *savingsPlansPurchaseArgs) { a.TermYears = 2 }, "invalid term_years"}, + {"invalid payment option", func(a *savingsPlansPurchaseArgs) { a.PaymentOption = "bogus" }, "invalid payment_option"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := validSavingsPlansArgs() + tc.mutate(&args) + _, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +func TestAWSSavingsPlansPurchaseHandleConfirmFalseRefuses(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &awsSavingsPlansPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validSavingsPlansArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(false) + + _, _, err := tool.handle(context.Background(), nil, args) + require.Error(t, err) + assert.False(t, resolveCalled) + assert.Contains(t, err.Error(), "confirm=true") +} + +func TestAWSSavingsPlansPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &awsSavingsPlansPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validSavingsPlansArgs() + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.False(t, resolveCalled) + assert.True(t, resp.DryRun) +} + +func TestAWSSavingsPlansPurchaseHandleRealPurchaseUsesScopedService(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "sp-1"}} + var gotService common.ServiceType + tool := &awsSavingsPlansPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "aws"}, + client: fake, + gotService: &gotService, + gotRegion: new(string), + }, nil + }, + } + args := validSavingsPlansArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, common.ServiceSavingsPlansCompute, gotService, "must resolve the plan-type-scoped client, not the umbrella sentinel") + assert.Equal(t, common.PurchaseSourceMCP, fake.lastOpts.Source) +} From 2046f9a9d0ead0f540f9290ebe272be83eccc4b8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:44:08 +0200 Subject: [PATCH 10/83] feat(mcp): azure vm and gcp compute engine purchase tools Adds cudly_azure_compute_ri_purchase and cudly_gcp_computeengine_cud_purchase as full real-purchase tools, not dry-run-only. The design doc's retry-safety concern (RI/CUD identifiers derived from a timestamp instead of the idempotency token) turned out to already be fixed upstream: Azure compute dedupes via DoIdempotentPurchaseTwoStep + FindReservationOrderByIdempotencyToken (issue #721), and GCP computeengine derives both the commitment name and the native RequestId from the token (issue #654). Re-verified against the committed client code before enabling real purchases here. Two provider-specific quirks surfaced while wiring this: - Azure's purchase body never sends a billingPlanType, so every purchase uses Azure's default (upfront) billing plan regardless of payment_option -- flagged in the tool description as a pre-existing gap this PR does not fix, not silently routed around. - GCP's PurchaseCommitment reads Recommendation.Details as a value common.ComputeDetails (memoryMBFromDetails), not a pointer like every AWS Details assertion; the GCP tool sets memory_gb as a required field and matches that value-type shape exactly. --- mcp/server.go | 2 + mcp/tools/azure_compute_ri.go | 154 +++++++++++++++++++++++ mcp/tools/azure_compute_ri_test.go | 122 ++++++++++++++++++ mcp/tools/gcp_computeengine_cud.go | 161 ++++++++++++++++++++++++ mcp/tools/gcp_computeengine_cud_test.go | 131 +++++++++++++++++++ 5 files changed, 570 insertions(+) create mode 100644 mcp/tools/azure_compute_ri.go create mode 100644 mcp/tools/azure_compute_ri_test.go create mode 100644 mcp/tools/gcp_computeengine_cud.go create mode 100644 mcp/tools/gcp_computeengine_cud_test.go diff --git a/mcp/server.go b/mcp/server.go index 1ed727245..de4cc2a11 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -30,6 +30,8 @@ func registrations() []tools.Registration { tools.NewAWSRDSRIPurchaseTool(), tools.NewAWSElastiCacheRIPurchaseTool(), tools.NewAWSSavingsPlansPurchaseTool(), + tools.NewAzureComputeRIPurchaseTool(), + tools.NewGCPComputeEngineCUDPurchaseTool(), } } diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go new file mode 100644 index 000000000..38854fd3c --- /dev/null +++ b/mcp/tools/azure_compute_ri.go @@ -0,0 +1,154 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +const azureComputeRIPurchaseName = "cudly_azure_compute_ri_purchase" + +// azureComputeRIPurchaseDescription flags a real, currently-unfixed gap +// (found while wiring this tool, not introduced by it): Azure's purchase +// body (providers/azure/services/compute/client.go:buildReservationBody) +// never sends a billingPlanType, so every purchase uses Azure's default +// (upfront) billing plan regardless of the payment_option requested here -- +// payment_option only affects the displayed cost estimate, not the actual +// invoice. Flagged rather than silently fixed (out of scope for this PR). +const azureComputeRIPurchaseDescription = "Purchase an Azure VM Reserved Instance. THIS SPENDS REAL MONEY when " + + "dry_run=false and confirm=true. Always call with dry_run=true first (the default) to validate your " + + "parameters before committing; a dry_run response never contacts Azure and never spends money. CAVEAT: " + + "Azure's purchase API always uses the default (upfront) billing plan today -- payment_option only affects " + + "the cost estimate shown here, not the actual invoice; this is a pre-existing gap, not something this tool " + + "controls." + +// azureComputeRIPurchaseArgs is the input schema for +// cudly_azure_compute_ri_purchase. Unlike EC2, Azure's purchase body needs no +// Recommendation.Details -- providers/azure/services/compute/client.go's +// buildReservationBody only reads Region/ResourceType/Count/Term. +type azureComputeRIPurchaseArgs struct { + Region string `json:"region" jsonschema:"Azure region, e.g. eastus"` + VMSize string `json:"vm_size" jsonschema:"Azure VM size (SKU), e.g. Standard_D2s_v3"` + Count int `json:"count" jsonschema:"number of VM instances to reserve, must be > 0"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + PaymentOption string `json:"payment_option" jsonschema:"payment schedule; see the CAVEAT in this tool's description"` + AzureSubscriptionID string `json:"azure_subscription_id,omitempty" jsonschema:"Azure subscription ID override; default uses AZURE_SUBSCRIPTION_ID"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` +} + +type azureComputeRIPurchaseTool struct { + createProvider func(name string, cfg *provider.ProviderConfig) (provider.Provider, error) +} + +// NewAzureComputeRIPurchaseTool builds the cudly_azure_compute_ri_purchase tool. +func NewAzureComputeRIPurchaseTool() Registration { + return &azureComputeRIPurchaseTool{createProvider: provider.CreateProvider} +} + +func (t *azureComputeRIPurchaseTool) Descriptor() Descriptor { + return Descriptor{ + Name: azureComputeRIPurchaseName, + Provider: "azure", + Product: "compute", + Action: "ri_purchase", + Description: azureComputeRIPurchaseDescription, + RealPurchaseEnabled: true, + ExamplePrompts: []string{ + "Preview buying 2 Standard_D2s_v3 Azure VM RIs in eastus for 3 years", + "Buy an Azure VM Reserved Instance for real in westeurope", + }, + } +} + +func (t *azureComputeRIPurchaseTool) Register(s *mcp.Server) error { + schema, err := BuildInputSchema[azureComputeRIPurchaseArgs](map[string]FieldOverride{ + "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, + "payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionPartialUpfront), string(PaymentOptionNoUpfront)}}, + "dry_run": {Default: true}, + "confirm": {Default: false}, + }) + if err != nil { + return err + } + mcp.AddTool(s, &mcp.Tool{ + Name: azureComputeRIPurchaseName, + Description: azureComputeRIPurchaseDescription, + InputSchema: schema, + }, t.handle) + return nil +} + +func (t *azureComputeRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args azureComputeRIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { + rec, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) + if err != nil { + return nil, PurchaseResponse{}, err + } + + resp, err := ExecutePurchase(ctx, PurchaseRequest{ + Region: args.Region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args), + }) + if err != nil { + return nil, PurchaseResponse{}, err + } + return nil, *resp, nil +} + +func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (common.Recommendation, bool, bool, error) { + if args.Region == "" { + return common.Recommendation{}, false, false, fmt.Errorf("region is required") + } + if args.VMSize == "" { + return common.Recommendation{}, false, false, fmt.Errorf("vm_size is required") + } + if args.Count <= 0 { + return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) + } + term, err := ValidateTermYears(args.TermYears) + if err != nil { + return common.Recommendation{}, false, false, err + } + paymentOption, err := ValidatePaymentOption(args.PaymentOption) + if err != nil { + return common.Recommendation{}, false, false, err + } + + rec := common.Recommendation{ + Provider: common.ProviderAzure, + Service: common.ServiceCompute, + Region: args.Region, + ResourceType: args.VMSize, + Count: args.Count, + CommitmentType: common.CommitmentReservedInstance, + Term: term.RecommendationTerm(), + PaymentOption: string(paymentOption), + } + + dryRun, confirm := true, false + if args.DryRun != nil { + dryRun = *args.DryRun + } + if args.Confirm != nil { + confirm = *args.Confirm + } + return rec, dryRun, confirm, nil +} + +func (t *azureComputeRIPurchaseTool) resolveClient(args azureComputeRIPurchaseArgs) ResolveClientFunc { + return func(ctx context.Context) (provider.ServiceClient, error) { + cfg := &provider.ProviderConfig{Name: string(common.ProviderAzure), AzureSubscriptionID: args.AzureSubscriptionID, Region: args.Region} + prov, err := t.createProvider(string(common.ProviderAzure), cfg) + if err != nil { + return nil, err + } + return prov.GetServiceClient(ctx, common.ServiceCompute, args.Region) + } +} diff --git a/mcp/tools/azure_compute_ri_test.go b/mcp/tools/azure_compute_ri_test.go new file mode 100644 index 000000000..19163d754 --- /dev/null +++ b/mcp/tools/azure_compute_ri_test.go @@ -0,0 +1,122 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +func validAzureComputeArgs() azureComputeRIPurchaseArgs { + return azureComputeRIPurchaseArgs{ + Region: "eastus", + VMSize: "Standard_D2s_v3", + Count: 2, + TermYears: 3, + PaymentOption: "no-upfront", + } +} + +func TestAzureComputeRecommendationFromArgs(t *testing.T) { + t.Parallel() + rec, dryRun, confirm, err := azureComputeRecommendationFromArgs(validAzureComputeArgs()) + require.NoError(t, err) + assert.True(t, dryRun) + assert.False(t, confirm) + assert.Equal(t, common.ProviderAzure, rec.Provider) + assert.Equal(t, common.ServiceCompute, rec.Service) + assert.Equal(t, "Standard_D2s_v3", rec.ResourceType) + assert.Equal(t, 2, rec.Count) + assert.Equal(t, "3yr", rec.Term) + assert.Nil(t, rec.Details, "Azure VM purchase reads no Recommendation.Details") +} + +func TestAzureComputeRecommendationFromArgsInvalid(t *testing.T) { + t.Parallel() + cases := []struct { + name string + mutate func(*azureComputeRIPurchaseArgs) + errSub string + }{ + {"missing region", func(a *azureComputeRIPurchaseArgs) { a.Region = "" }, "region is required"}, + {"missing vm_size", func(a *azureComputeRIPurchaseArgs) { a.VMSize = "" }, "vm_size is required"}, + {"zero count", func(a *azureComputeRIPurchaseArgs) { a.Count = 0 }, "count must be"}, + {"invalid term", func(a *azureComputeRIPurchaseArgs) { a.TermYears = 2 }, "invalid term_years"}, + {"invalid payment option", func(a *azureComputeRIPurchaseArgs) { a.PaymentOption = "bogus" }, "invalid payment_option"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := validAzureComputeArgs() + tc.mutate(&args) + _, _, _, err := azureComputeRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +func TestAzureComputeRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &azureComputeRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validAzureComputeArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(false) + + _, _, err := tool.handle(context.Background(), nil, args) + require.Error(t, err) + assert.False(t, resolveCalled) + assert.Contains(t, err.Error(), "confirm=true") +} + +func TestAzureComputeRIPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &azureComputeRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validAzureComputeArgs() + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.False(t, resolveCalled) + assert.True(t, resp.DryRun) +} + +func TestAzureComputeRIPurchaseHandleRealPurchase(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "azure-res-1"}} + var gotService common.ServiceType + tool := &azureComputeRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "azure"}, + client: fake, + gotService: &gotService, + gotRegion: new(string), + }, nil + }, + } + args := validAzureComputeArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, common.ServiceCompute, gotService) + assert.Equal(t, common.PurchaseSourceMCP, fake.lastOpts.Source) +} diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go new file mode 100644 index 000000000..e1fbbf57f --- /dev/null +++ b/mcp/tools/gcp_computeengine_cud.go @@ -0,0 +1,161 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +const gcpComputeEngineCUDPurchaseName = "cudly_gcp_computeengine_cud_purchase" + +const gcpComputeEngineCUDPurchaseDescription = "Purchase a GCP Compute Engine Committed Use Discount (CUD). THIS " + + "SPENDS REAL MONEY when dry_run=false and confirm=true. Always call with dry_run=true first (the default) " + + "to validate your parameters before committing; a dry_run response never contacts GCP and never spends " + + "money. A CUD commits vCPUs and memory directly (not an instance count): vcpu_count is the number of vCPUs " + + "and memory_gb is the amount of memory to commit." + +// gcpComputeEngineCUDPurchaseArgs is the input schema for +// cudly_gcp_computeengine_cud_purchase. memory_gb is required: unlike AWS/ +// Azure, providers/gcp/services/computeengine/client.go's buildInsertRequest +// reads Recommendation.Details as a *value* common.ComputeDetails (not a +// pointer, unlike every AWS Details assertion) and hard-errors when +// MemoryGB is absent or <= 0 rather than guessing a vCPU:memory ratio. +type gcpComputeEngineCUDPurchaseArgs struct { + Region string `json:"region" jsonschema:"GCP region, e.g. us-central1"` + MachineType string `json:"machine_type" jsonschema:"GCP machine type family for the commitment, e.g. n2-standard-4"` + 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"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + GCPProjectID string `json:"gcp_project_id,omitempty" jsonschema:"GCP project ID override; default uses ambient project"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` +} + +type gcpComputeEngineCUDPurchaseTool struct { + createProvider func(name string, cfg *provider.ProviderConfig) (provider.Provider, error) +} + +// NewGCPComputeEngineCUDPurchaseTool builds the cudly_gcp_computeengine_cud_purchase tool. +func NewGCPComputeEngineCUDPurchaseTool() Registration { + return &gcpComputeEngineCUDPurchaseTool{createProvider: provider.CreateProvider} +} + +func (t *gcpComputeEngineCUDPurchaseTool) Descriptor() Descriptor { + return Descriptor{ + Name: gcpComputeEngineCUDPurchaseName, + Provider: "gcp", + Product: "computeengine", + Action: "cud_purchase", + Description: gcpComputeEngineCUDPurchaseDescription, + RealPurchaseEnabled: true, + ExamplePrompts: []string{ + "Preview a 3-year CUD for 8 vCPUs and 32 GB memory in us-central1", + "Buy a 1-year Compute Engine CUD for real: 4 vCPUs, 16 GB memory", + }, + } +} + +func (t *gcpComputeEngineCUDPurchaseTool) Register(s *mcp.Server) error { + schema, err := BuildInputSchema[gcpComputeEngineCUDPurchaseArgs](map[string]FieldOverride{ + "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, + "dry_run": {Default: true}, + "confirm": {Default: false}, + }) + if err != nil { + return err + } + mcp.AddTool(s, &mcp.Tool{ + Name: gcpComputeEngineCUDPurchaseName, + Description: gcpComputeEngineCUDPurchaseDescription, + InputSchema: schema, + }, t.handle) + return nil +} + +func (t *gcpComputeEngineCUDPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args gcpComputeEngineCUDPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { + rec, dryRun, confirm, err := gcpComputeEngineRecommendationFromArgs(args) + if err != nil { + return nil, PurchaseResponse{}, err + } + + resp, err := ExecutePurchase(ctx, PurchaseRequest{ + Region: args.Region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args), + }) + if err != nil { + return nil, PurchaseResponse{}, err + } + return nil, *resp, nil +} + +func gcpCUDPurchaseRequiredFields(args gcpComputeEngineCUDPurchaseArgs) error { + if args.Region == "" { + return fmt.Errorf("region is required") + } + if args.MachineType == "" { + return fmt.Errorf("machine_type is required") + } + if args.VCPUCount <= 0 { + return fmt.Errorf("vcpu_count must be > 0, got %d", args.VCPUCount) + } + if args.MemoryGB <= 0 { + return fmt.Errorf("memory_gb must be > 0, got %v", args.MemoryGB) + } + return nil +} + +// gcpComputeEngineRecommendationFromArgs validates args and builds the +// common.Recommendation to purchase, plus the effective dry_run/confirm +// booleans. Details is set as a value (common.ComputeDetails{}), not a +// pointer, to match the value type assertion in +// providers/gcp/services/computeengine/client.go's memoryMBFromDetails. +func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs) (common.Recommendation, bool, bool, error) { + if err := gcpCUDPurchaseRequiredFields(args); err != nil { + return common.Recommendation{}, false, false, err + } + term, err := ValidateTermYears(args.TermYears) + if err != nil { + return common.Recommendation{}, false, false, err + } + + rec := common.Recommendation{ + Provider: common.ProviderGCP, + Service: common.ServiceCompute, + Region: args.Region, + ResourceType: args.MachineType, + Count: args.VCPUCount, + CommitmentType: common.CommitmentCUD, + Term: term.RecommendationTerm(), + Details: common.ComputeDetails{ + InstanceType: args.MachineType, + MemoryGB: args.MemoryGB, + }, + } + + dryRun, confirm := true, false + if args.DryRun != nil { + dryRun = *args.DryRun + } + if args.Confirm != nil { + confirm = *args.Confirm + } + return rec, dryRun, confirm, nil +} + +func (t *gcpComputeEngineCUDPurchaseTool) resolveClient(args gcpComputeEngineCUDPurchaseArgs) ResolveClientFunc { + return func(ctx context.Context) (provider.ServiceClient, error) { + cfg := &provider.ProviderConfig{Name: string(common.ProviderGCP), GCPProjectID: args.GCPProjectID, Region: args.Region} + prov, err := t.createProvider(string(common.ProviderGCP), cfg) + if err != nil { + return nil, err + } + return prov.GetServiceClient(ctx, common.ServiceCompute, args.Region) + } +} diff --git a/mcp/tools/gcp_computeengine_cud_test.go b/mcp/tools/gcp_computeengine_cud_test.go new file mode 100644 index 000000000..fd4e7fda2 --- /dev/null +++ b/mcp/tools/gcp_computeengine_cud_test.go @@ -0,0 +1,131 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +func validGCPCUDArgs() gcpComputeEngineCUDPurchaseArgs { + return gcpComputeEngineCUDPurchaseArgs{ + Region: "us-central1", + MachineType: "n2-standard-4", + VCPUCount: 4, + MemoryGB: 16, + TermYears: 1, + } +} + +func TestGCPComputeEngineRecommendationFromArgs(t *testing.T) { + t.Parallel() + rec, dryRun, confirm, err := gcpComputeEngineRecommendationFromArgs(validGCPCUDArgs()) + require.NoError(t, err) + assert.True(t, dryRun) + assert.False(t, confirm) + assert.Equal(t, common.ProviderGCP, rec.Provider) + assert.Equal(t, common.ServiceCompute, rec.Service) + assert.Equal(t, common.CommitmentCUD, rec.CommitmentType) + assert.Equal(t, "n2-standard-4", rec.ResourceType) + assert.Equal(t, 4, rec.Count) + assert.Equal(t, "1yr", rec.Term) + + // Details MUST be a value common.ComputeDetails, not a pointer: + // providers/gcp/services/computeengine/client.go's memoryMBFromDetails + // type-asserts rec.Details.(common.ComputeDetails), unlike every AWS + // Details assertion which expects a pointer. + details, ok := rec.Details.(common.ComputeDetails) + require.True(t, ok, "Details must be a value common.ComputeDetails, not *common.ComputeDetails") + assert.InDelta(t, 16.0, details.MemoryGB, 0.001) +} + +func TestGCPComputeEngineRecommendationFromArgsInvalid(t *testing.T) { + t.Parallel() + cases := []struct { + name string + mutate func(*gcpComputeEngineCUDPurchaseArgs) + errSub string + }{ + {"missing region", func(a *gcpComputeEngineCUDPurchaseArgs) { a.Region = "" }, "region is required"}, + {"missing machine_type", func(a *gcpComputeEngineCUDPurchaseArgs) { a.MachineType = "" }, "machine_type is required"}, + {"zero vcpu_count", func(a *gcpComputeEngineCUDPurchaseArgs) { a.VCPUCount = 0 }, "vcpu_count must be"}, + {"zero memory_gb", func(a *gcpComputeEngineCUDPurchaseArgs) { a.MemoryGB = 0 }, "memory_gb must be"}, + {"negative memory_gb", func(a *gcpComputeEngineCUDPurchaseArgs) { a.MemoryGB = -1 }, "memory_gb must be"}, + {"invalid term", func(a *gcpComputeEngineCUDPurchaseArgs) { a.TermYears = 2 }, "invalid term_years"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := validGCPCUDArgs() + tc.mutate(&args) + _, _, _, err := gcpComputeEngineRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +func TestGCPComputeEngineCUDPurchaseHandleConfirmFalseRefuses(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &gcpComputeEngineCUDPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validGCPCUDArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(false) + + _, _, err := tool.handle(context.Background(), nil, args) + require.Error(t, err) + assert.False(t, resolveCalled) + assert.Contains(t, err.Error(), "confirm=true") +} + +func TestGCPComputeEngineCUDPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &gcpComputeEngineCUDPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validGCPCUDArgs() + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.False(t, resolveCalled) + assert.True(t, resp.DryRun) +} + +func TestGCPComputeEngineCUDPurchaseHandleRealPurchase(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "cud-1"}} + var gotService common.ServiceType + tool := &gcpComputeEngineCUDPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "gcp"}, + client: fake, + gotService: &gotService, + gotRegion: new(string), + }, nil + }, + } + args := validGCPCUDArgs() + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, common.ServiceCompute, gotService) + assert.Equal(t, common.PurchaseSourceMCP, fake.lastOpts.Source) +} From 98bee6d0524d6ee19198d49e50ebb1886cf356a1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:45:21 +0200 Subject: [PATCH 11/83] docs(mcp): add mcp/README.md Documents install, per-provider credential setup (matching each provider's ambient-credential model, plus the per-call aws_profile/azure_subscription_id/gcp_project_id overrides), launch, ~/.claude/mcp.json registration, a worked search-then-preview-then- purchase example, the safety model (dry_run/confirm gate, typed enum validation, idempotency tokens), the Azure billing-plan and GCP vCPU/memory caveats flagged in the two previous commits, and troubleshooting. --- mcp/README.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 mcp/README.md diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 000000000..deb5b417f --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,117 @@ +# CUDly MCP Server + +`cudly-mcp` exposes CUDly's reserved-capacity search and purchase tools (AWS EC2/RDS/ElastiCache/OpenSearch/Redshift/MemoryDB/Savings Plans, Azure VM Reservations, GCP Compute Engine CUDs) to any MCP client -- Claude Code, Claude Desktop, or another MCP-speaking agent -- as a local process. It is a thin wrapper around the same in-process Go packages (`pkg/provider`, `pkg/common`) the `ri-helper` CLI uses; it never shells out to `ri-helper`, and it is not deployed anywhere (see [Deployment model](#deployment-model)). + +Every purchase tool is dry-run by default (`dry_run=true`) and requires an explicit `confirm=true` alongside `dry_run=false` before it spends money. See [Safety model](#safety-model). + +## Install + +From the repository root: + +```bash +go build -o cudly-mcp ./cmd/cudly-mcp +``` + +Or run directly without a separate build step: + +```bash +go run ./cmd/cudly-mcp +``` + +There is no separate module or release artifact for `cudly-mcp` yet -- build it from a checkout of this repository. + +## Configure credentials + +The server takes no CUDly-specific configuration of its own. Each provider tool authenticates the same way the corresponding CUDly CLI path does, and every tool call also accepts a per-call override (`aws_profile`, `azure_subscription_id`, `gcp_project_id`) so one running server instance can serve requests against different accounts/subscriptions/projects without a restart. + +### AWS + +One of, in the usual SDK precedence order: + +- `AWS_PROFILE` (matches a profile in `~/.aws/config` / `~/.aws/credentials`) +- `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (+ optional `AWS_SESSION_TOKEN`) +- An IAM role (EC2 instance profile, ECS task role, etc.) + +Per-call override: pass `aws_profile` on any AWS tool call to use a specific named profile for that call only. + +### Azure + +One of: + +- A service principal via `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID` +- Local `az login` state (picked up by `azidentity.NewDefaultAzureCredential`) + +Set `AZURE_SUBSCRIPTION_ID` to select the subscription, or pass `azure_subscription_id` on a per-call basis to override it. + +### GCP + +One of: + +- `GOOGLE_APPLICATION_CREDENTIALS` pointing at a service-account JSON key file +- Application Default Credentials (`gcloud auth application-default login`) + +Pass `gcp_project_id` on a per-call basis to override the ambient project. + +## Launch + +```bash +./cudly-mcp +``` + +The server speaks MCP over stdio and logs diagnostics to stderr; it does not print anything to stdout other than protocol traffic, so it is safe to launch directly from an MCP client's process-spawning config (below) rather than through a wrapper script. + +## Register with an MCP client + +Add an entry to your client's MCP server config. For Claude Code, this is `~/.claude/mcp.json`: + +```json +{ + "mcpServers": { + "cudly": { + "command": "/absolute/path/to/cudly-mcp", + "env": { + "AWS_PROFILE": "my-aws-profile", + "AZURE_SUBSCRIPTION_ID": "00000000-0000-0000-0000-000000000000" + } + } + } +} +``` + +`env` is optional -- omit it entirely to rely on whatever ambient credentials are already active in the shell that launches the client, or set only the provider(s) you actually use. + +## Worked example + +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 shows the cost/savings figures from the recommendation without contacting AWS or spending anything. +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; 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 and echoes back the cost/savings figures already known from the recommendation. +- A real purchase requires **both** `dry_run=false` **and** `confirm=true`. `dry_run=false` with `confirm=false` (or vice versa) is refused with a structured error, not silently downgraded to a preview or silently ignored. +- 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, so retrying an identical tool call after a network error dedupes at the provider instead of buying twice. +- Provider/SDK failures surface their full error text back to the caller; nothing is swallowed. + +## Caveats and known gaps + +These are pre-existing behaviours in the underlying purchase clients, not something introduced by or specific to the MCP server -- flagged here so you know what to expect: + +- **Azure VM Reservations always bill upfront.** `cudly_azure_compute_ri_purchase`'s `payment_option` affects the cost estimate shown in a dry-run preview but is not sent to Azure's purchase API, which always uses Azure's default billing plan regardless of what you request. +- **GCP Compute Engine CUDs commit resources, not instances.** `cudly_gcp_computeengine_cud_purchase` takes `vcpu_count` and `memory_gb` directly (a CUD is a vCPU+memory commitment), not an instance count -- there is no implicit vCPU-per-instance conversion. + +## Deployment model + +`cudly-mcp` is a local/desktop process, not a deployed service: it is intentionally kept out of `iac/`, `terraform/`, and the `internal/api` Lambda packaging path. Run it on the same machine as your MCP client. + +## Troubleshooting + +- **"provider ... is not configured" / credential errors**: confirm the relevant environment variable(s) from [Configure credentials](#configure-credentials) are set in the shell (or the client's `env` block) that launches `cudly-mcp`, or pass the matching per-call override (`aws_profile` / `azure_subscription_id` / `gcp_project_id`). +- **Azure/GCP purchase calls appear to hang**: Azure Reservations and GCP Compute Commitments both provision asynchronously after the purchase call returns; a `success=true` response means the purchase request was accepted, not necessarily that the resource is already active in the portal/console. Re-run `cudly_search_recommendations` or check the provider console if you need to confirm activation state. +- **Rate limits / throttling from the cloud provider**: retry the same tool call with the same parameters -- the idempotency token guarantees a retry cannot double-purchase. +- **"invalid ... must be one of ..." errors**: every enum-typed parameter (term, payment option, engine, az_config, sp_type, scope, tenancy, platform) is validated against an explicit allow-list; call `cudly_list_commitment_actions` or re-check this README's per-tool schema for the exact accepted values. From 44aee0f748acad2e114ff190d30d1a91d7c22b53 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:53:15 +0200 Subject: [PATCH 12/83] fix(mcp): resolve golangci-lint v2.10.1 findings Reproduced the CI-pinned golangci-lint version (v2.10.1, per ci.yml/feedback_golangci_exact_ci_version) locally and fixed everything it flagged across the mcp package: named result parameters on every *RecommendationFromArgs helper (gocritic unnamedResult), a direct Descriptor->ActionEntry struct conversion instead of a field-by-field literal (staticcheck S1016), American-English spelling in comments (misspell), and three govet shadow warnings from an inner `if err :=` reusing the outer named result's `err` identifier. 0 issues on a clean rerun. --- mcp/tools/aws_ec2_ri.go | 6 +++--- mcp/tools/aws_elasticache_ri.go | 10 +++++----- mcp/tools/aws_rds_ri.go | 10 +++++----- mcp/tools/aws_savingsplans.go | 8 ++++---- mcp/tools/aws_simple_ri.go | 6 +++--- mcp/tools/aws_simple_ri_test.go | 2 +- mcp/tools/azure_compute_ri.go | 6 +++--- mcp/tools/enums.go | 10 +++++----- mcp/tools/gcp_computeengine_cud.go | 10 +++++----- mcp/tools/list_commitment_actions.go | 14 +++++--------- 10 files changed, 39 insertions(+), 43 deletions(-) diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go index 4cfe71e3b..6ecdcfb29 100644 --- a/mcp/tools/aws_ec2_ri.go +++ b/mcp/tools/aws_ec2_ri.go @@ -158,7 +158,7 @@ func effectiveDryRunConfirm(args ec2RIPurchaseArgs) (dryRun, confirm bool) { // ec2RecommendationFromArgs validates every field of args and builds the // common.Recommendation to purchase, plus the effective dry_run/confirm // booleans. -func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (common.Recommendation, bool, bool, error) { +func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { if args.Region == "" { return common.Recommendation{}, false, false, fmt.Errorf("region is required") } @@ -181,7 +181,7 @@ func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (common.Recommendation, b return common.Recommendation{}, false, false, err } - rec := common.Recommendation{ + rec = common.Recommendation{ Provider: common.ProviderAWS, Service: common.ServiceEC2, Region: args.Region, @@ -198,7 +198,7 @@ func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (common.Recommendation, b }, } - dryRun, confirm := effectiveDryRunConfirm(args) + dryRun, confirm = effectiveDryRunConfirm(args) return rec, dryRun, confirm, nil } diff --git a/mcp/tools/aws_elasticache_ri.go b/mcp/tools/aws_elasticache_ri.go index 7ff78dce1..8aabcd4ba 100644 --- a/mcp/tools/aws_elasticache_ri.go +++ b/mcp/tools/aws_elasticache_ri.go @@ -110,9 +110,9 @@ func elasticacheRIPurchaseRequiredFields(args elasticacheRIPurchaseArgs) error { // elasticacheRecommendationFromArgs validates args and builds the // common.Recommendation to purchase, plus the effective dry_run/confirm // booleans. -func elasticacheRecommendationFromArgs(args elasticacheRIPurchaseArgs) (common.Recommendation, bool, bool, error) { - if err := elasticacheRIPurchaseRequiredFields(args); err != nil { - return common.Recommendation{}, false, false, err +func elasticacheRecommendationFromArgs(args elasticacheRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { + if fieldErr := elasticacheRIPurchaseRequiredFields(args); fieldErr != nil { + return common.Recommendation{}, false, false, fieldErr } term, err := ValidateTermYears(args.TermYears) if err != nil { @@ -127,7 +127,7 @@ func elasticacheRecommendationFromArgs(args elasticacheRIPurchaseArgs) (common.R return common.Recommendation{}, false, false, err } - rec := common.Recommendation{ + rec = common.Recommendation{ Provider: common.ProviderAWS, Service: common.ServiceElastiCache, Region: args.Region, @@ -142,7 +142,7 @@ func elasticacheRecommendationFromArgs(args elasticacheRIPurchaseArgs) (common.R }, } - dryRun, confirm := true, false + dryRun, confirm = true, false if args.DryRun != nil { dryRun = *args.DryRun } diff --git a/mcp/tools/aws_rds_ri.go b/mcp/tools/aws_rds_ri.go index 51ce0e72d..af68f6079 100644 --- a/mcp/tools/aws_rds_ri.go +++ b/mcp/tools/aws_rds_ri.go @@ -116,9 +116,9 @@ func rdsRIPurchaseRequiredFields(args rdsRIPurchaseArgs) error { // rdsRecommendationFromArgs validates args and builds the // common.Recommendation to purchase, plus the effective dry_run/confirm // booleans. -func rdsRecommendationFromArgs(args rdsRIPurchaseArgs) (common.Recommendation, bool, bool, error) { - if err := rdsRIPurchaseRequiredFields(args); err != nil { - return common.Recommendation{}, false, false, err +func rdsRecommendationFromArgs(args rdsRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { + if fieldErr := rdsRIPurchaseRequiredFields(args); fieldErr != nil { + return common.Recommendation{}, false, false, fieldErr } term, err := ValidateTermYears(args.TermYears) if err != nil { @@ -133,7 +133,7 @@ func rdsRecommendationFromArgs(args rdsRIPurchaseArgs) (common.Recommendation, b return common.Recommendation{}, false, false, err } - rec := common.Recommendation{ + rec = common.Recommendation{ Provider: common.ProviderAWS, Service: common.ServiceRDS, Region: args.Region, @@ -149,7 +149,7 @@ func rdsRecommendationFromArgs(args rdsRIPurchaseArgs) (common.Recommendation, b }, } - dryRun, confirm := true, false + dryRun, confirm = true, false if args.DryRun != nil { dryRun = *args.DryRun } diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index 0e85f4916..b777ddb8d 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -110,7 +110,7 @@ func (t *awsSavingsPlansPurchaseTool) handle(ctx context.Context, _ *mcp.CallToo // savingsPlanRecommendationFromArgs validates args and builds the // common.Recommendation to purchase, the effective region to resolve the // service client against, and the effective dry_run/confirm booleans. -func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (common.Recommendation, string, bool, bool, error) { +func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec common.Recommendation, region string, dryRun, confirm bool, err error) { if args.HourlyCommitment <= 0 { return common.Recommendation{}, "", false, false, fmt.Errorf("hourly_commitment must be > 0, got %v", args.HourlyCommitment) } @@ -130,7 +130,7 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (common.Re return common.Recommendation{}, "", false, false, fmt.Errorf("region is required for sp_type=%s", SPTypeEC2Instance) } - region := args.Region + region = args.Region if region == "" { region = savingsPlansAccountLevelRegion } @@ -144,7 +144,7 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (common.Re // depth on top of the ValidateSPType check above). service := savingsplans.ServiceTypeForPlanType(spTypes.SavingsPlanType(spType)) - rec := common.Recommendation{ + rec = common.Recommendation{ Provider: common.ProviderAWS, Service: service, Region: region, @@ -159,7 +159,7 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (common.Re }, } - dryRun, confirm := true, false + dryRun, confirm = true, false if args.DryRun != nil { dryRun = *args.DryRun } diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index 1cda9428f..70b786c93 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -147,7 +147,7 @@ func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReq return nil, *resp, nil } -func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchaseArgs) (common.Recommendation, bool, bool, error) { +func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { if args.Region == "" { return common.Recommendation{}, false, false, fmt.Errorf("region is required") } @@ -166,7 +166,7 @@ func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchas return common.Recommendation{}, false, false, err } - rec := common.Recommendation{ + rec = common.Recommendation{ Provider: common.ProviderAWS, Service: t.spec.service, Region: args.Region, @@ -177,7 +177,7 @@ func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchas PaymentOption: string(paymentOption), } - dryRun, confirm := true, false + dryRun, confirm = true, false if args.DryRun != nil { dryRun = *args.DryRun } diff --git a/mcp/tools/aws_simple_ri_test.go b/mcp/tools/aws_simple_ri_test.go index 7432e6e47..0c1a0932b 100644 --- a/mcp/tools/aws_simple_ri_test.go +++ b/mcp/tools/aws_simple_ri_test.go @@ -12,7 +12,7 @@ import ( ) // simpleToolConstructors covers every simpleAWSRIPurchaseTool instance so -// the shared safety-rail behaviour (confirm gate, dry_run gate, boundary +// the shared safety-rail behavior (confirm gate, dry_run gate, boundary // validation, real-purchase wiring) is proven once per product rather than // hand-copied three times. func simpleToolConstructors() map[string]func() Registration { diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index 38854fd3c..8ea662378 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -102,7 +102,7 @@ func (t *azureComputeRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTool return nil, *resp, nil } -func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (common.Recommendation, bool, bool, error) { +func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { if args.Region == "" { return common.Recommendation{}, false, false, fmt.Errorf("region is required") } @@ -121,7 +121,7 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (common return common.Recommendation{}, false, false, err } - rec := common.Recommendation{ + rec = common.Recommendation{ Provider: common.ProviderAzure, Service: common.ServiceCompute, Region: args.Region, @@ -132,7 +132,7 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (common PaymentOption: string(paymentOption), } - dryRun, confirm := true, false + dryRun, confirm = true, false if args.DryRun != nil { dryRun = *args.DryRun } diff --git a/mcp/tools/enums.go b/mcp/tools/enums.go index ac614ca1e..a1e6f9b06 100644 --- a/mcp/tools/enums.go +++ b/mcp/tools/enums.go @@ -13,7 +13,7 @@ import ( // schedule. It is validated at the MCP tool boundary before being copied // onto common.Recommendation.PaymentOption (which stays a bare string there // for backward compatibility with existing CSV/DB rows -- see -// pkg/common/types.go) so a caller can never smuggle an unrecognised payment +// pkg/common/types.go) so a caller can never smuggle an unrecognized payment // term into a purchase. type PaymentOption string @@ -87,7 +87,7 @@ func ValidateSPType(s string) (SPType, error) { } // AZConfig is the RDS deployment topology (single-AZ vs multi-AZ), which -// carries a different price and offering catalogue per +// carries a different price and offering catalog per // providers/aws/services/rds/client.go:314-322. type AZConfig string @@ -115,7 +115,7 @@ func ValidateAZConfig(s string) (AZConfig, error) { // ProductDescription (providers/aws/services/ec2/client.go:419). Reusing the // SDK's own enum constants -- rather than inventing a "linux"/"windows" // vocabulary -- means an outbound offering lookup can never carry a bare -// string literal that drifts from what the SDK actually recognises +// string literal that drifts from what the SDK actually recognizes // (feedback_sdk_enum_string_literals). func ValidatePlatform(s string) (ec2types.RIProductDescription, error) { switch ec2types.RIProductDescription(s) { @@ -132,7 +132,7 @@ func ValidatePlatform(s string) (ec2types.RIProductDescription, error) { } // Tenancy is the EC2 RI tenancy dimension. Values match ec2types.Tenancy -// (providers/aws/services/ec2/client.go:309-318 canonicalises them further, +// (providers/aws/services/ec2/client.go:309-318 canonicalizes them further, // but "default"/"dedicated" already pass through unchanged). type Tenancy string @@ -154,7 +154,7 @@ func ValidateTenancy(s string) (Tenancy, error) { // Scope is the EC2 RI applicability dimension. Values are the lowercase, // hyphenated form that providers/aws/services/ec2/client.go:330-339 -// (canonicalizeEC2Scope) recognises and normalises to the SDK's +// (canonicalizeEC2Scope) recognizes and normalizes to the SDK's // ec2types.Scope casing ("Region" / "Availability Zone"). type Scope string diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index e1fbbf57f..831bd40fc 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -116,16 +116,16 @@ func gcpCUDPurchaseRequiredFields(args gcpComputeEngineCUDPurchaseArgs) error { // booleans. Details is set as a value (common.ComputeDetails{}), not a // pointer, to match the value type assertion in // providers/gcp/services/computeengine/client.go's memoryMBFromDetails. -func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs) (common.Recommendation, bool, bool, error) { - if err := gcpCUDPurchaseRequiredFields(args); err != nil { - return common.Recommendation{}, false, false, err +func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { + if fieldErr := gcpCUDPurchaseRequiredFields(args); fieldErr != nil { + return common.Recommendation{}, false, false, fieldErr } term, err := ValidateTermYears(args.TermYears) if err != nil { return common.Recommendation{}, false, false, err } - rec := common.Recommendation{ + rec = common.Recommendation{ Provider: common.ProviderGCP, Service: common.ServiceCompute, Region: args.Region, @@ -139,7 +139,7 @@ func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs }, } - dryRun, confirm := true, false + dryRun, confirm = true, false if args.DryRun != nil { dryRun = *args.DryRun } diff --git a/mcp/tools/list_commitment_actions.go b/mcp/tools/list_commitment_actions.go index a685aa366..c394d78ec 100644 --- a/mcp/tools/list_commitment_actions.go +++ b/mcp/tools/list_commitment_actions.go @@ -74,15 +74,11 @@ func (t *listCommitmentActionsTool) Register(s *mcp.Server) error { func (t *listCommitmentActionsTool) handle(_ context.Context, _ *mcp.CallToolRequest, _ listCommitmentActionsArgs) (*mcp.CallToolResult, listCommitmentActionsResult, error) { actions := make([]ActionEntry, 0, len(t.descriptors)) for _, d := range t.descriptors { - actions = append(actions, ActionEntry{ - Name: d.Name, - Provider: d.Provider, - Product: d.Product, - Action: d.Action, - Description: d.Description, - RealPurchaseEnabled: d.RealPurchaseEnabled, - ExamplePrompts: d.ExamplePrompts, - }) + // ActionEntry's fields are identical in name, type, and order to + // Descriptor's -- only the json tags differ -- so a direct + // conversion is equivalent to (and clearer than) a field-by-field + // struct literal. + actions = append(actions, ActionEntry(d)) } return nil, listCommitmentActionsResult{Actions: actions}, nil } From d2f39a0b0290f145a7f46cf19396ca402a7b74e3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:56:17 +0200 Subject: [PATCH 13/83] test(mcp): add end-to-end search-then-dry-run-purchase test Drives the real MCP protocol path (a real Client connected to the real NewServer over an in-memory transport) rather than a bare Go function call: connects, lists tools, then calls cudly_aws_ec2_ri_purchase with dry_run omitted (must default to true). Proves every tool's schema registers without error at connect time and that a dry-run purchase returns structured cost JSON through the full protocol stack with no AWS credentials configured in this test environment. Verified stable under -race across repeated runs. --- mcp/server_test.go | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/mcp/server_test.go b/mcp/server_test.go index b535e805c..c53b79ccd 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1,9 +1,12 @@ package mcp import ( + "context" + "encoding/json" "strings" "testing" + gosdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -44,6 +47,66 @@ func TestRegistryNonEmpty(t *testing.T) { // purchase tool that forgets one fails this test rather than shipping a // tool description that quietly omits the safety framing every other // purchase tool carries. +// TestEndToEndSearchThenDryRunPurchase drives the real MCP protocol path +// end to end -- a real Client connected to the real NewServer over an +// in-memory transport, not a bare Go function call -- through the exact +// chain the README's worked example describes: list the catalog, then call +// cudly_aws_ec2_ri_purchase with dry_run=true. It proves the tool schema +// registered without error (AddTool's schema inference/validation runs at +// connect time) and that a dry-run purchase returns structured cost JSON +// without any AWS credentials configured in this test environment -- +// confirming dry_run=true never reaches the real purchase path even through +// the full protocol stack, not just the Go-level unit tests in +// mcp/tools/aws_ec2_ri_test.go. +func TestEndToEndSearchThenDryRunPurchase(t *testing.T) { + t.Parallel() + ctx := context.Background() + + server, err := NewServer("test") + require.NoError(t, err) + + clientTransport, serverTransport := gosdk.NewInMemoryTransports() + go func() { + _ = server.Run(ctx, serverTransport) + }() + + client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + defer session.Close() + + toolsList, err := session.ListTools(ctx, nil) + require.NoError(t, err) + names := make(map[string]bool, len(toolsList.Tools)) + for _, tl := range toolsList.Tools { + names[tl.Name] = true + } + assert.True(t, names["cudly_list_commitment_actions"]) + assert.True(t, names["cudly_aws_ec2_ri_purchase"]) + + result, err := session.CallTool(ctx, &gosdk.CallToolParams{ + Name: "cudly_aws_ec2_ri_purchase", + Arguments: map[string]any{ + "region": "us-east-1", + "instance_type": "m5.large", + "count": 3, + "term_years": 3, + "payment_option": "no-upfront", + // dry_run/confirm omitted: must default to true/false. + }, + }) + require.NoError(t, err) + require.False(t, result.IsError, "dry_run purchase must not be a tool error") + + structured, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + var resp tools.PurchaseResponse + require.NoError(t, json.Unmarshal(structured, &resp)) + assert.True(t, resp.DryRun) + assert.True(t, resp.Success) + assert.Empty(t, resp.Error) +} + func TestRealPurchaseToolsDocumentMoneyImpactAndDryRun(t *testing.T) { t.Parallel() for _, r := range registrations() { From 7a06b58fc6242a64c69fe0f41deb61fac0e31d3b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:17:36 +0200 Subject: [PATCH 14/83] fix(mcp): derive idempotency key from every price-affecting dimension idempotencyKeyFor only hashed provider/account/region/service/resource_type/ count/term/payment_option, ignoring rec.Details entirely. Two materially different purchases that only differ in a Details field (e.g. a $5/hr vs a $50/hr Compute Savings Plan, or a Linux vs Windows EC2 RI) collided on the same token, so the provider's idempotency dedupe would silently skip the second purchase instead of buying it. Fold every field of the Details type each purchase tool populates (ComputeDetails, DatabaseDetails, CacheDetails, SavingsPlanDetails) into the key via a deterministic per-type encoder, and drop rec.Account from the key since no *FromArgs constructor in this package ever sets it (an always-empty component gave no real discrimination and misled readers of the key format). Also corrects the idempotencyKeyFor docstring, which claimed the key already distinguished materially different requests. Added regression tests proving two Savings Plans requests differing only in hourly_commitment, and two EC2 RI requests differing only in platform, now derive different tokens. Both fail on the pre-fix code (same token) and pass after this change. --- mcp/tools/purchase.go | 85 ++++++++++++++++++++++++++++++++++---- mcp/tools/purchase_test.go | 50 ++++++++++++++++++++++ 2 files changed, 127 insertions(+), 8 deletions(-) diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index 796ab8400..dce429bfc 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -75,19 +75,88 @@ type PurchaseResponse struct { Error string `json:"error,omitempty"` } -// idempotencyKeyFor derives a stable per-request key from the fields that -// identify what is being bought, so a caller re-driving the exact same tool -// call (e.g. after a network timeout) reuses the same -// common.DeriveIdempotencyToken output and the provider dedupes the retry -// instead of double-purchasing. A materially different request (different -// count, region, term, ...) always derives a different key. This is a +// idempotencyKeyFor derives a stable per-request key from every field that +// identifies what is being bought: provider, region, service, resource type, +// count, term, payment option, plus every service-specific dimension held in +// rec.Details (see detailsKeyComponent) -- platform/tenancy/scope for EC2 +// RIs, engine/az_config for RDS RIs, engine for ElastiCache RIs, hourly +// commitment/instance family for Savings Plans, memory for GCP CUDs. A +// caller re-driving the exact same tool call (e.g. after a network timeout) +// reuses the same common.DeriveIdempotencyToken output and the provider +// dedupes the retry instead of double-purchasing; a request that differs in +// ANY price- or identity-affecting dimension derives a different key instead +// of silently colliding with an unrelated purchase (issue found in review: +// a $5/hr and $50/hr Compute Savings Plan previously shared a token because +// only HourlyCommitment differed and Details was never consulted). This is a // request-scoped substitute for the purchase_executions row that the CLI/web // paths use as their idempotency anchor (pkg/common/tokens.go) -- the MCP // server has no such row, so the request's own identifying fields play that // role. +// +// rec.Account is deliberately excluded: no *FromArgs constructor in this +// package populates it today, so folding it in would add an always-empty, +// misleading key component rather than real discrimination. func idempotencyKeyFor(region string, rec common.Recommendation) string { - return fmt.Sprintf("mcp:%s:%s:%s:%s:%s:%d:%s:%s", - rec.Provider, rec.Account, region, rec.Service, rec.ResourceType, rec.Count, rec.Term, rec.PaymentOption) + return fmt.Sprintf("mcp:%s:%s:%s:%s:%d:%s:%s:%s", + rec.Provider, region, rec.Service, rec.ResourceType, rec.Count, rec.Term, rec.PaymentOption, + detailsKeyComponent(rec.Details)) +} + +// detailsKeyComponent returns a canonical, deterministic encoding of every +// field in rec.Details that the purchase tools in this package populate, so +// idempotencyKeyFor can fold service-specific price-affecting dimensions +// into the token. Each case lists every field of its concrete Details type +// explicitly (not a hand-picked subset) so a field added to one of these +// types later shows up here as a visible diff rather than a silent key gap. +// Returns "" for nil or an unrecognized Details (e.g. +// azure_compute_ri.go's tool, whose recommendation carries no Details at +// all). +func detailsKeyComponent(details common.ServiceDetails) string { + switch d := details.(type) { + case *common.ComputeDetails: + return computeDetailsKey(d) + case common.ComputeDetails: + return computeDetailsKey(&d) + case *common.DatabaseDetails: + return databaseDetailsKey(d) + case *common.CacheDetails: + return cacheDetailsKey(d) + case *common.SavingsPlanDetails: + return savingsPlanDetailsKey(d) + default: + return "" + } +} + +func computeDetailsKey(d *common.ComputeDetails) string { + if d == nil { + return "" + } + return fmt.Sprintf("instance_type=%s;platform=%s;tenancy=%s;scope=%s;vcpu=%d;memory_gb=%g", + d.InstanceType, d.Platform, d.Tenancy, d.Scope, d.VCPU, d.MemoryGB) +} + +func databaseDetailsKey(d *common.DatabaseDetails) string { + if d == nil { + return "" + } + return fmt.Sprintf("engine=%s;engine_version=%s;az_config=%s;instance_class=%s;deployment=%s", + d.Engine, d.EngineVersion, d.AZConfig, d.InstanceClass, d.Deployment) +} + +func cacheDetailsKey(d *common.CacheDetails) string { + if d == nil { + return "" + } + return fmt.Sprintf("engine=%s;node_type=%s;shards=%d", d.Engine, d.NodeType, d.Shards) +} + +func savingsPlanDetailsKey(d *common.SavingsPlanDetails) string { + if d == nil { + return "" + } + return fmt.Sprintf("plan_type=%s;hourly_commitment=%g;coverage=%s;instance_family=%s;region=%s;offering_id=%s", + d.PlanType, d.HourlyCommitment, d.Coverage, d.InstanceFamily, d.Region, d.OfferingID) } // ExecutePurchase runs the shared dry_run/confirm safety gate and, for a diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 8cb39ce8b..7334c2368 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -261,3 +261,53 @@ func TestExecutePurchaseResolveClientErrorSurfaced(t *testing.T) { assert.Nil(t, resp) assert.Contains(t, err.Error(), "no AWS credentials found") } + +// TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment proves finding +// 1 of the adversarial review of the purchase feature: two Savings Plans +// requests that differ only in hourly_commitment (a $5/hr vs a $50/hr +// Compute Savings Plan) must derive different idempotency tokens. Before the +// fix, idempotencyKeyFor never consulted rec.Details at all, so these two +// materially different purchases collided on the same token and AWS would +// have silently deduped the second call as a "retry" of the first instead +// of buying a second, larger plan. +func TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment(t *testing.T) { + t.Parallel() + cheapArgs := validSavingsPlansArgs() + cheapArgs.HourlyCommitment = 5 + expensiveArgs := validSavingsPlansArgs() + expensiveArgs.HourlyCommitment = 50 + + cheapRec, region, _, _, err := savingsPlanRecommendationFromArgs(cheapArgs) + require.NoError(t, err) + expensiveRec, _, _, _, err := savingsPlanRecommendationFromArgs(expensiveArgs) + require.NoError(t, err) + + cheapKey := idempotencyKeyFor(region, cheapRec) + expensiveKey := idempotencyKeyFor(region, expensiveRec) + assert.NotEqual(t, cheapKey, expensiveKey, + "a $5/hr and a $50/hr Compute Savings Plan must not derive the same idempotency key") +} + +// TestIdempotencyKeyDistinguishesEC2Platform proves the second half of +// finding 1: an EC2 RI purchase for Linux vs Windows, with every other field +// (region/instance_type/count/term/payment_option) identical, must not +// collide on the same idempotency key -- Platform is a price- and +// product-affecting dimension carried in rec.Details, and the pre-fix key +// derivation ignored Details entirely. +func TestIdempotencyKeyDistinguishesEC2Platform(t *testing.T) { + t.Parallel() + linuxArgs := validEC2Args() + linuxArgs.Platform = "Linux/UNIX" + windowsArgs := validEC2Args() + windowsArgs.Platform = "Windows" + + linuxRec, _, _, err := ec2RecommendationFromArgs(linuxArgs) + require.NoError(t, err) + windowsRec, _, _, err := ec2RecommendationFromArgs(windowsArgs) + require.NoError(t, err) + + linuxKey := idempotencyKeyFor(linuxArgs.Region, linuxRec) + windowsKey := idempotencyKeyFor(windowsArgs.Region, windowsRec) + assert.NotEqual(t, linuxKey, windowsKey, + "a Linux and a Windows EC2 RI purchase must not derive the same idempotency key") +} From 426ae487310bc6ece4825605077f7382d1a97d23 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:21:43 +0200 Subject: [PATCH 15/83] fix(mcp): omit unknown purchase cost/savings instead of reporting 0 No purchase tool's *FromArgs constructor populates Recommendation's OnDemandCost/CommitmentCost/EstimatedSavings/SavingsPercentage (they build a fresh Recommendation from the caller's typed args, not a priced search result), and some provider clients (AWS EC2 RIs, Savings Plans) never populate PurchaseResult.Cost either. Because PurchaseResponse used plain float64 fields without omitempty, every dry-run preview and most real purchases reported cost/on_demand_cost/estimated_savings/savings_percentage as a literal 0, indistinguishable from a genuinely free purchase. Change those four fields to *float64 with omitempty, and add nonZeroCostPtr so a value is only surfaced when it is actually known (a real purchase's result.Cost still passes through when the provider populates it). Update mcp/README.md's worked example and safety-model section, which claimed a dry-run preview echoes back "the cost/savings figures already known from the recommendation" -- it validates parameters and reports pricing only when genuinely known. Split the purchase_test.go fixture into testRecommendation() (mirrors what real tools actually build: no cost fields) and testRecommendationWithCost() (used only to prove pass-through when a value is genuinely present); the old single fixture hand-set cost fields no real tool produces, which masked this finding. Added TestExecutePurchasePreviewOmitsUnknownCostFields, which asserts the four fields are nil and absent from the marshaled JSON. Both new and updated assertions fail to even compile against the pre-fix float64 fields (the type itself could not represent "unknown"), and pass after this change. --- mcp/README.md | 4 +- mcp/tools/purchase.go | 60 ++++++++++++++++++-------- mcp/tools/purchase_test.go | 88 ++++++++++++++++++++++++++++++-------- 3 files changed, 115 insertions(+), 37 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index deb5b417f..d5586d474 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -85,14 +85,14 @@ Add an entry to your client's MCP server config. For Claude Code, this is `~/.cl 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 shows the cost/savings figures from the recommendation without contacting AWS or spending anything. +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`). 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; 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 and echoes back the cost/savings figures already known from the recommendation. +- `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. - A real purchase requires **both** `dry_run=false` **and** `confirm=true`. `dry_run=false` with `confirm=false` (or vice versa) is refused with a structured error, not silently downgraded to a preview or silently ignored. - 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, so retrying an identical tool call after a network error dedupes at the provider instead of buying twice. diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index dce429bfc..152fb4133 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -62,17 +62,41 @@ type PurchaseRequest struct { // error) because it crosses the MCP JSON-RPC boundary as tool output, not a // protocol-level error -- ExecutePurchase itself still returns a Go error // for gate refusals and provider-call failures. +// +// Cost/OnDemandCost/EstimatedSavings/SavingsPercentage are pointers with +// omitempty: none of the *FromArgs constructors in this package populate +// Recommendation's cost fields (they build a fresh Recommendation from the +// caller's typed args, not from a priced search result), and some provider +// clients (e.g. AWS EC2 RIs, Savings Plans) never populate +// PurchaseResult.Cost either. A plain float64 could not distinguish "not +// known" from "genuinely $0", so every response reported 0 for money fields +// it never actually priced. A pointer that's nil (and omitted from the JSON +// payload entirely) when no real value exists lets a caller tell "unknown" +// apart from "confirmed zero" (feedback_nullable_not_zero). type PurchaseResponse struct { - Success bool `json:"success"` - DryRun bool `json:"dry_run"` - CommitmentID string `json:"commitment_id,omitempty"` - Cost float64 `json:"cost"` - OnDemandCost float64 `json:"on_demand_cost"` - EstimatedSavings float64 `json:"estimated_savings"` - SavingsPercentage float64 `json:"savings_percentage"` - EffectiveDate string `json:"effective_date,omitempty"` - TermYears int `json:"term_years,omitempty"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + DryRun bool `json:"dry_run"` + CommitmentID string `json:"commitment_id,omitempty"` + Cost *float64 `json:"cost,omitempty"` + OnDemandCost *float64 `json:"on_demand_cost,omitempty"` + EstimatedSavings *float64 `json:"estimated_savings,omitempty"` + SavingsPercentage *float64 `json:"savings_percentage,omitempty"` + EffectiveDate string `json:"effective_date,omitempty"` + TermYears int `json:"term_years,omitempty"` + Error string `json:"error,omitempty"` +} + +// nonZeroCostPtr returns a pointer to v, or nil when v is exactly zero. Cost +// and savings fields on common.Recommendation and common.PurchaseResult are +// plain (unpointered) float64s that upstream code sometimes never populates +// (see the PurchaseResponse doc comment above); this treats an unpopulated +// zero as "unknown" rather than fabricating a real $0 figure the caller +// never priced. +func nonZeroCostPtr(v float64) *float64 { + if v == 0 { + return nil + } + return &v } // idempotencyKeyFor derives a stable per-request key from every field that @@ -174,10 +198,10 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons return &PurchaseResponse{ Success: true, DryRun: true, - Cost: rec.CommitmentCost, - OnDemandCost: rec.OnDemandCost, - EstimatedSavings: rec.EstimatedSavings, - SavingsPercentage: rec.SavingsPercentage, + Cost: nonZeroCostPtr(rec.CommitmentCost), + OnDemandCost: nonZeroCostPtr(rec.OnDemandCost), + EstimatedSavings: nonZeroCostPtr(rec.EstimatedSavings), + SavingsPercentage: nonZeroCostPtr(rec.SavingsPercentage), }, nil } @@ -206,10 +230,10 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons Success: result.Success, DryRun: result.DryRun, CommitmentID: result.CommitmentID, - Cost: result.Cost, - OnDemandCost: rec.OnDemandCost, - EstimatedSavings: rec.EstimatedSavings, - SavingsPercentage: rec.SavingsPercentage, + Cost: nonZeroCostPtr(result.Cost), + OnDemandCost: nonZeroCostPtr(rec.OnDemandCost), + EstimatedSavings: nonZeroCostPtr(rec.EstimatedSavings), + SavingsPercentage: nonZeroCostPtr(rec.SavingsPercentage), EffectiveDate: result.Timestamp.Format(time.RFC3339), } if result.Error != nil { diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 7334c2368..e9efecb99 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "encoding/json" "errors" "testing" @@ -48,23 +49,39 @@ func (f *fakeServiceClient) GetValidResourceTypes(_ context.Context) ([]string, var _ provider.ServiceClient = (*fakeServiceClient)(nil) +// testRecommendation mirrors what a real purchase tool's *FromArgs +// constructor actually builds: none of them populate +// OnDemandCost/CommitmentCost/EstimatedSavings/SavingsPercentage (they build +// a fresh Recommendation from the caller's typed args, not from a priced +// search result), so this fixture leaves those fields at their zero value +// too. An earlier version of this fixture hand-set those fields, which +// masked the all-responses-report-0 finding from review -- see +// TestExecutePurchasePreviewOmitsUnknownCostFields. func testRecommendation() common.Recommendation { return common.Recommendation{ - Provider: common.ProviderAWS, - Account: "123456789012", - Service: common.ServiceEC2, - Region: "us-east-1", - ResourceType: "m5.large", - Count: 3, - Term: "3yr", - PaymentOption: "no-upfront", - OnDemandCost: 1000, - CommitmentCost: 600, - EstimatedSavings: 400, - SavingsPercentage: 40, + Provider: common.ProviderAWS, + Account: "123456789012", + Service: common.ServiceEC2, + Region: "us-east-1", + ResourceType: "m5.large", + Count: 3, + Term: "3yr", + PaymentOption: "no-upfront", } } +// testRecommendationWithCost extends testRecommendation with real cost +// figures, used only to prove ExecutePurchase passes a genuinely-known cost +// through to the response when one is present. +func testRecommendationWithCost() common.Recommendation { + rec := testRecommendation() + rec.OnDemandCost = 1000 + rec.CommitmentCost = 600 + rec.EstimatedSavings = 400 + rec.SavingsPercentage = 40 + return rec +} + func TestDecidePurchaseMode(t *testing.T) { t.Parallel() cases := []struct { @@ -106,7 +123,7 @@ func TestExecutePurchaseDryRunNeverCallsProvider(t *testing.T) { resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ Region: "us-east-1", - Recommendation: testRecommendation(), + Recommendation: testRecommendationWithCost(), DryRun: true, Confirm: true, ResolveClient: resolve, @@ -117,10 +134,47 @@ func TestExecutePurchaseDryRunNeverCallsProvider(t *testing.T) { assert.False(t, resolveCalled, "dry_run=true must never resolve a service client") assert.True(t, resp.DryRun) assert.True(t, resp.Success) - assert.Equal(t, 600.0, resp.Cost) - assert.Equal(t, 1000.0, resp.OnDemandCost) - assert.Equal(t, 400.0, resp.EstimatedSavings) - assert.Equal(t, 40.0, resp.SavingsPercentage) + require.NotNil(t, resp.Cost, "a genuinely-known cost must be passed through, not dropped") + assert.Equal(t, 600.0, *resp.Cost) + require.NotNil(t, resp.OnDemandCost) + assert.Equal(t, 1000.0, *resp.OnDemandCost) + require.NotNil(t, resp.EstimatedSavings) + assert.Equal(t, 400.0, *resp.EstimatedSavings) + require.NotNil(t, resp.SavingsPercentage) + assert.Equal(t, 40.0, *resp.SavingsPercentage) +} + +// TestExecutePurchasePreviewOmitsUnknownCostFields proves finding 2 of the +// adversarial review: a dry-run preview built from a Recommendation that +// mirrors what real purchase tools actually construct (no cost fields set, +// since no *FromArgs constructor in this package populates them) must not +// report cost/on_demand_cost/estimated_savings/savings_percentage as a real +// 0 -- that would be indistinguishable from a confirmed $0 purchase. The +// pointer fields must be nil, and therefore omitted from the JSON payload +// entirely rather than serialized as 0. +func TestExecutePurchasePreviewOmitsUnknownCostFields(t *testing.T) { + t.Parallel() + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", + Recommendation: testRecommendation(), + DryRun: true, + Confirm: false, + }) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Nil(t, resp.Cost) + assert.Nil(t, resp.OnDemandCost) + assert.Nil(t, resp.EstimatedSavings) + assert.Nil(t, resp.SavingsPercentage) + + raw, err := json.Marshal(resp) + require.NoError(t, err) + body := string(raw) + assert.NotContains(t, body, `"cost"`, "unknown cost must be omitted from the JSON payload, not reported as 0") + assert.NotContains(t, body, `"on_demand_cost"`) + assert.NotContains(t, body, `"estimated_savings"`) + assert.NotContains(t, body, `"savings_percentage"`) } // TestExecutePurchaseUnconfirmedRealPurchaseRefused proves confirm=false From 3da2d34d4438e185059973e0acb763711573dc23 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:24:05 +0200 Subject: [PATCH 16/83] fix(mcp): reject Azure payment_option Azure will not honor azure_compute_ri.go validated payment_option against the shared AWS/Azure/ GCP enum and then silently dropped it: Azure's purchase API (buildReservationBody) has no billing-plan parameter and always bills upfront, so a caller requesting no-upfront or partial-upfront got a real purchase billed upfront anyway, under a payment schedule they never chose. Reject any payment_option other than all-upfront with an explicit error instead of silently mismatching, per the project's fail-loud convention. Update the tool description, field schema comment, and mcp/README.md's caveat section to describe the new rejection behavior instead of the old "affects the cost estimate but not the invoice" framing. validAzureComputeArgs() now uses all-upfront, the one value Azure actually honors. Added TestAzureComputeRecommendationFromArgsRejectsUnhonoredPaymentOption (no-upfront and partial-upfront both rejected) and TestAzureComputeRecommendationFromArgsAcceptsAllUpfront. The rejection test fails on the pre-fix code (no error returned) and passes after this change. --- mcp/README.md | 2 +- mcp/tools/azure_compute_ri.go | 38 ++++++++++++++++++++--------- mcp/tools/azure_compute_ri_test.go | 39 +++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index d5586d474..2a18e55af 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -102,7 +102,7 @@ Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_ These are pre-existing behaviours in the underlying purchase clients, not something introduced by or specific to the MCP server -- flagged here so you know what to expect: -- **Azure VM Reservations always bill upfront.** `cudly_azure_compute_ri_purchase`'s `payment_option` affects the cost estimate shown in a dry-run preview but is not sent to Azure's purchase API, which always uses Azure's default billing plan regardless of what you request. +- **Azure VM Reservations always bill upfront.** Azure's purchase API has no billing-plan parameter and always bills upfront, so `cudly_azure_compute_ri_purchase` requires `payment_option=all-upfront` and rejects any other value with an explicit error rather than silently purchasing under a schedule Azure would not honor. - **GCP Compute Engine CUDs commit resources, not instances.** `cudly_gcp_computeengine_cud_purchase` takes `vcpu_count` and `memory_gb` directly (a CUD is a vCPU+memory commitment), not an instance count -- there is no implicit vCPU-per-instance conversion. ## Deployment model diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index 8ea662378..195b3ba10 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -12,19 +12,23 @@ import ( const azureComputeRIPurchaseName = "cudly_azure_compute_ri_purchase" -// azureComputeRIPurchaseDescription flags a real, currently-unfixed gap -// (found while wiring this tool, not introduced by it): Azure's purchase -// body (providers/azure/services/compute/client.go:buildReservationBody) -// never sends a billingPlanType, so every purchase uses Azure's default -// (upfront) billing plan regardless of the payment_option requested here -- -// payment_option only affects the displayed cost estimate, not the actual -// invoice. Flagged rather than silently fixed (out of scope for this PR). +// azureComputeRIPurchaseDescription flags a real, pre-existing gap in +// Azure's purchase API (found while wiring this tool, not introduced by it): +// Azure's purchase body (providers/azure/services/compute/client.go: +// buildReservationBody) never sends a billingPlanType, so every purchase +// uses Azure's default (upfront) billing plan regardless of the +// payment_option requested here. Rather than silently accepting a +// payment_option Azure will not actually honor -- and purchasing under a +// different billing schedule than the caller chose -- this tool requires +// payment_option=all-upfront and rejects any other value with an explicit +// error (fail loud, not a silent mismatch; see +// azureComputeRecommendationFromArgs). const azureComputeRIPurchaseDescription = "Purchase an Azure VM Reserved Instance. THIS SPENDS REAL MONEY when " + "dry_run=false and confirm=true. Always call with dry_run=true first (the default) to validate your " + "parameters before committing; a dry_run response never contacts Azure and never spends money. CAVEAT: " + - "Azure's purchase API always uses the default (upfront) billing plan today -- payment_option only affects " + - "the cost estimate shown here, not the actual invoice; this is a pre-existing gap, not something this tool " + - "controls." + "Azure Reserved Instances only support all-upfront billing -- Azure's purchase API has no billing-plan " + + "parameter and always bills upfront -- so payment_option must be all-upfront; any other value is rejected " + + "with an error rather than silently purchased under a schedule Azure will not honor." // azureComputeRIPurchaseArgs is the input schema for // cudly_azure_compute_ri_purchase. Unlike EC2, Azure's purchase body needs no @@ -35,7 +39,7 @@ type azureComputeRIPurchaseArgs struct { VMSize string `json:"vm_size" jsonschema:"Azure VM size (SKU), e.g. Standard_D2s_v3"` Count int `json:"count" jsonschema:"number of VM instances to reserve, must be > 0"` TermYears int `json:"term_years" jsonschema:"commitment length in years"` - PaymentOption string `json:"payment_option" jsonschema:"payment schedule; see the CAVEAT in this tool's description"` + PaymentOption string `json:"payment_option" jsonschema:"payment schedule; Azure only honors all-upfront, see the CAVEAT in this tool's description"` AzureSubscriptionID string `json:"azure_subscription_id,omitempty" jsonschema:"Azure subscription ID override; default uses AZURE_SUBSCRIPTION_ID"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` @@ -120,6 +124,18 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co if err != nil { return common.Recommendation{}, false, false, err } + // Azure's purchase API has no billing-plan parameter and always bills + // upfront (see the azureComputeRIPurchaseDescription doc comment), so a + // payment_option other than all-upfront would be silently purchased + // under a schedule the caller never chose. Reject it now instead of + // letting a real purchase proceed under a mismatched payment_option. + if paymentOption != PaymentOptionAllUpfront { + return common.Recommendation{}, false, false, fmt.Errorf( + "azure reserved instances only support all-upfront billing (got payment_option=%q): "+ + "azure's purchase API has no billing-plan parameter and always bills upfront, so any other "+ + "payment_option would be purchased under a different schedule than requested rather than honored", + paymentOption) + } rec = common.Recommendation{ Provider: common.ProviderAzure, diff --git a/mcp/tools/azure_compute_ri_test.go b/mcp/tools/azure_compute_ri_test.go index 19163d754..fe10901b6 100644 --- a/mcp/tools/azure_compute_ri_test.go +++ b/mcp/tools/azure_compute_ri_test.go @@ -11,13 +11,18 @@ import ( "github.com/LeanerCloud/CUDly/pkg/provider" ) +// validAzureComputeArgs uses payment_option=all-upfront, the only schedule +// Azure Reserved Instances actually honor (see +// TestAzureComputeRecommendationFromArgsRejectsUnhonoredPaymentOption): +// azure_compute_ri.go rejects any other value rather than silently +// purchasing under a mismatched billing schedule. func validAzureComputeArgs() azureComputeRIPurchaseArgs { return azureComputeRIPurchaseArgs{ Region: "eastus", VMSize: "Standard_D2s_v3", Count: 2, TermYears: 3, - PaymentOption: "no-upfront", + PaymentOption: "all-upfront", } } @@ -59,6 +64,38 @@ func TestAzureComputeRecommendationFromArgsInvalid(t *testing.T) { } } +// TestAzureComputeRecommendationFromArgsRejectsUnhonoredPaymentOption proves +// finding 3 of the adversarial review: azure_compute_ri.go used to validate +// payment_option against the shared AWS/Azure/GCP enum and then silently +// ignore it -- Azure's purchase API has no billing-plan parameter and always +// bills upfront, so a caller requesting no-upfront or partial-upfront got a +// real purchase billed upfront, under a payment schedule they never chose. +// The tool must now reject any payment_option other than all-upfront with +// an explicit error instead of silently mismatching. +func TestAzureComputeRecommendationFromArgsRejectsUnhonoredPaymentOption(t *testing.T) { + t.Parallel() + for _, po := range []string{"no-upfront", "partial-upfront"} { + t.Run(po, func(t *testing.T) { + args := validAzureComputeArgs() + args.PaymentOption = po + _, _, _, err := azureComputeRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), "all-upfront") + }) + } +} + +// TestAzureComputeRecommendationFromArgsAcceptsAllUpfront proves the one +// payment_option Azure actually honors still succeeds. +func TestAzureComputeRecommendationFromArgsAcceptsAllUpfront(t *testing.T) { + t.Parallel() + args := validAzureComputeArgs() + args.PaymentOption = "all-upfront" + rec, _, _, err := azureComputeRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "all-upfront", rec.PaymentOption) +} + func TestAzureComputeRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { t.Parallel() resolveCalled := false From d98a68778a13a7e3930765e02be02c6df5ee65f1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:30:50 +0200 Subject: [PATCH 17/83] fix(mcp): scope Azure payment_option gate to real purchases only Azure's purchase API has no billing-plan parameter and always bills upfront, so a payment_option other than all-upfront can never be honored for a real purchase. The previous check rejected any other value unconditionally, which also blocked a dry_run=true preview from validating those parameters even though a preview never spends money. Reuse decidePurchaseMode inside azureComputeRecommendationFromArgs so the all-upfront-only rejection applies only when the call would actually execute (dry_run=false, confirm=true); a preview now accepts any valid payment_option, and a confirm-missing call still surfaces the shared confirm=true error from ExecutePurchase. --- mcp/tools/azure_compute_ri.go | 44 ++++++++++++------- mcp/tools/azure_compute_ri_test.go | 68 +++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 18 deletions(-) diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index 195b3ba10..af039cf46 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -20,15 +20,19 @@ const azureComputeRIPurchaseName = "cudly_azure_compute_ri_purchase" // payment_option requested here. Rather than silently accepting a // payment_option Azure will not actually honor -- and purchasing under a // different billing schedule than the caller chose -- this tool requires -// payment_option=all-upfront and rejects any other value with an explicit -// error (fail loud, not a silent mismatch; see -// azureComputeRecommendationFromArgs). +// payment_option=all-upfront for a real purchase (dry_run=false, +// confirm=true) and rejects any other value there with an explicit error +// (fail loud, not a silent mismatch; see azureComputeRecommendationFromArgs). +// A dry_run=true preview still validates other payment_option values so a +// caller can rehearse parameters without hitting this rejection. const azureComputeRIPurchaseDescription = "Purchase an Azure VM Reserved Instance. THIS SPENDS REAL MONEY when " + "dry_run=false and confirm=true. Always call with dry_run=true first (the default) to validate your " + "parameters before committing; a dry_run response never contacts Azure and never spends money. CAVEAT: " + "Azure Reserved Instances only support all-upfront billing -- Azure's purchase API has no billing-plan " + - "parameter and always bills upfront -- so payment_option must be all-upfront; any other value is rejected " + - "with an error rather than silently purchased under a schedule Azure will not honor." + "parameter and always bills upfront -- so payment_option must be all-upfront for a real purchase " + + "(dry_run=false, confirm=true); any other value is rejected there with an error rather than silently " + + "purchased under a schedule Azure will not honor. A dry_run=true preview still validates other " + + "payment_option values so a caller can rehearse parameters before learning that." // azureComputeRIPurchaseArgs is the input schema for // cudly_azure_compute_ri_purchase. Unlike EC2, Azure's purchase body needs no @@ -124,14 +128,29 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co if err != nil { return common.Recommendation{}, false, false, err } + + dryRun, confirm = true, false + if args.DryRun != nil { + dryRun = *args.DryRun + } + if args.Confirm != nil { + confirm = *args.Confirm + } + // Azure's purchase API has no billing-plan parameter and always bills // upfront (see the azureComputeRIPurchaseDescription doc comment), so a // payment_option other than all-upfront would be silently purchased - // under a schedule the caller never chose. Reject it now instead of - // letting a real purchase proceed under a mismatched payment_option. - if paymentOption != PaymentOptionAllUpfront { + // under a schedule the caller never chose. Reusing decidePurchaseMode + // (the same real-purchase-vs-preview gate ExecutePurchase applies) scopes + // this rejection to a call that would actually spend money: a preview + // (dry_run=true) still validates every other parameter so a caller can + // rehearse a no-upfront/partial-upfront request before learning it can + // never be honored for real, and a call that's merely missing confirm + // surfaces that error from ExecutePurchase's shared gate instead of this + // Azure-specific one. + if mode, _ := decidePurchaseMode(dryRun, confirm); mode == modeExecute && paymentOption != PaymentOptionAllUpfront { return common.Recommendation{}, false, false, fmt.Errorf( - "azure reserved instances only support all-upfront billing (got payment_option=%q): "+ + "azure reserved instances only support all-upfront billing for a real purchase (got payment_option=%q): "+ "azure's purchase API has no billing-plan parameter and always bills upfront, so any other "+ "payment_option would be purchased under a different schedule than requested rather than honored", paymentOption) @@ -148,13 +167,6 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co PaymentOption: string(paymentOption), } - dryRun, confirm = true, false - if args.DryRun != nil { - dryRun = *args.DryRun - } - if args.Confirm != nil { - confirm = *args.Confirm - } return rec, dryRun, confirm, nil } diff --git a/mcp/tools/azure_compute_ri_test.go b/mcp/tools/azure_compute_ri_test.go index fe10901b6..1d34556b2 100644 --- a/mcp/tools/azure_compute_ri_test.go +++ b/mcp/tools/azure_compute_ri_test.go @@ -70,14 +70,17 @@ func TestAzureComputeRecommendationFromArgsInvalid(t *testing.T) { // ignore it -- Azure's purchase API has no billing-plan parameter and always // bills upfront, so a caller requesting no-upfront or partial-upfront got a // real purchase billed upfront, under a payment schedule they never chose. -// The tool must now reject any payment_option other than all-upfront with -// an explicit error instead of silently mismatching. +// A call that would actually execute (dry_run=false, confirm=true) must +// reject any payment_option other than all-upfront with an explicit error +// instead of silently mismatching. func TestAzureComputeRecommendationFromArgsRejectsUnhonoredPaymentOption(t *testing.T) { t.Parallel() for _, po := range []string{"no-upfront", "partial-upfront"} { t.Run(po, func(t *testing.T) { args := validAzureComputeArgs() args.PaymentOption = po + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) _, _, _, err := azureComputeRecommendationFromArgs(args) require.Error(t, err) assert.Contains(t, err.Error(), "all-upfront") @@ -85,6 +88,28 @@ func TestAzureComputeRecommendationFromArgsRejectsUnhonoredPaymentOption(t *test } } +// TestAzureComputeRecommendationFromArgsPreviewAllowsUnhonoredPaymentOption +// proves the CodeRabbit-requested scoping: a dry_run=true preview never +// spends money, so it must still validate a no-upfront/partial-upfront +// payment_option instead of rejecting it outright -- the caller learns +// about the all-upfront-only constraint from the tool description and the +// real-purchase rejection, not from being blocked at preview time. +func TestAzureComputeRecommendationFromArgsPreviewAllowsUnhonoredPaymentOption(t *testing.T) { + t.Parallel() + for _, po := range []string{"no-upfront", "partial-upfront"} { + t.Run(po, func(t *testing.T) { + args := validAzureComputeArgs() + args.PaymentOption = po + args.DryRun = boolPtr(true) + rec, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) + require.NoError(t, err) + assert.True(t, dryRun) + assert.False(t, confirm) + assert.Equal(t, po, rec.PaymentOption) + }) + } +} + // TestAzureComputeRecommendationFromArgsAcceptsAllUpfront proves the one // payment_option Azure actually honors still succeeds. func TestAzureComputeRecommendationFromArgsAcceptsAllUpfront(t *testing.T) { @@ -115,6 +140,45 @@ func TestAzureComputeRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { assert.Contains(t, err.Error(), "confirm=true") } +func TestAzureComputeRIPurchaseHandleDryRunAllowsUnhonoredPaymentOption(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &azureComputeRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validAzureComputeArgs() + args.PaymentOption = "no-upfront" + args.DryRun = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.False(t, resolveCalled) + assert.True(t, resp.DryRun) +} + +func TestAzureComputeRIPurchaseHandleRealPurchaseRejectsUnhonoredPaymentOption(t *testing.T) { + t.Parallel() + resolveCalled := false + tool := &azureComputeRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + resolveCalled = true + return nil, nil + }, + } + args := validAzureComputeArgs() + args.PaymentOption = "partial-upfront" + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, _, err := tool.handle(context.Background(), nil, args) + require.Error(t, err) + assert.False(t, resolveCalled) + assert.Contains(t, err.Error(), "all-upfront") +} + func TestAzureComputeRIPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { t.Parallel() resolveCalled := false From 4bcd761382781119289cb63c3df16e8aaf00eea1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:35:37 +0200 Subject: [PATCH 18/83] feat(mcp): expose include_regions/exclude_regions on search_recommendations common.RecommendationParams already supports IncludeRegions and ExcludeRegions, but cudly_search_recommendations neither accepted nor forwarded them, so an MCP caller could not restrict (or exclude) regions the way the CLI's config does. Add include_regions/exclude_regions array params to the tool schema and forward them into RecommendationParams. --- mcp/tools/search_recommendations.go | 4 ++++ mcp/tools/search_recommendations_test.go | 25 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index a09cb9950..0c8566aca 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -26,6 +26,8 @@ type searchRecommendationsArgs struct { Provider string `json:"provider" jsonschema:"cloud provider to search"` Service string `json:"service" jsonschema:"service to search, e.g. ec2, rds, elasticache, compute, computeengine"` Region string `json:"region,omitempty" jsonschema:"region to search; omit for account/global-level services such as Savings Plans"` + IncludeRegions []string `json:"include_regions,omitempty" jsonschema:"restrict the search to these regions, in addition to (or instead of) region"` + ExcludeRegions []string `json:"exclude_regions,omitempty" jsonschema:"exclude these regions from the search"` LookbackPeriod string `json:"lookback_period,omitempty" jsonschema:"cost/usage lookback window backing the recommendation"` TermYears int `json:"term_years,omitempty" jsonschema:"filter to a specific commitment term; omit to search all terms"` PaymentOption string `json:"payment_option,omitempty" jsonschema:"filter to a specific payment schedule; omit to search all"` @@ -184,6 +186,8 @@ func recommendationParamsFromArgs(service common.ServiceType, term string, args Term: term, PaymentOption: args.PaymentOption, AccountFilter: args.AccountFilter, + IncludeRegions: args.IncludeRegions, + ExcludeRegions: args.ExcludeRegions, IncludeSPTypes: args.IncludeSPTypes, ExcludeSPTypes: args.ExcludeSPTypes, } diff --git a/mcp/tools/search_recommendations_test.go b/mcp/tools/search_recommendations_test.go index ad074f846..ff8fccac0 100644 --- a/mcp/tools/search_recommendations_test.go +++ b/mcp/tools/search_recommendations_test.go @@ -96,6 +96,31 @@ func TestSearchRecommendationsHappyPath(t *testing.T) { assert.Equal(t, "us-east-1", client.lastParams.Region) } +// TestSearchRecommendationsForwardsRegionFilters proves finding B of the +// CodeRabbit review: common.RecommendationParams has IncludeRegions and +// ExcludeRegions, but the tool neither accepted nor forwarded them, so a +// caller could not restrict a search to (or exclude) specific regions the +// way the CLI's config supports. include_regions/exclude_regions must reach +// the underlying RecommendationsClient call unchanged. +func TestSearchRecommendationsForwardsRegionFilters(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + IncludeRegions: []string{"us-east-1", "us-west-2"}, + ExcludeRegions: []string{"eu-west-1"}, + }) + + require.NoError(t, err) + require.NotNil(t, client.lastParams) + assert.Equal(t, []string{"us-east-1", "us-west-2"}, client.lastParams.IncludeRegions) + assert.Equal(t, []string{"eu-west-1"}, client.lastParams.ExcludeRegions) +} + func TestSearchRecommendationsInvalidProvider(t *testing.T) { t.Parallel() tool := newTestSearchTool(&fakeProvider{}) From b022ed85a074ea1ca2132a9a2e6a7880960ef97c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:35:53 +0200 Subject: [PATCH 19/83] fix(mcp): validate Database Savings Plan term/payment constraints AWS's Database Savings Plans support only a one-year term billed no-upfront (confirmed against AWS's Database Savings Plans announcement, aws.amazon.com/about-aws/whats-new/2025/12/ database-savings-plans-savings) -- unlike Compute, EC2Instance, and SageMaker plans, there is no 3-year term and no all-upfront/ partial-upfront option. Add validateDatabaseSPConstraints to reject a mismatched term_years or payment_option for sp_type=Database before building the recommendation, instead of letting AWS reject it at purchase time. Split the growing validation chain out of savingsPlanRecommendationFromArgs into validateSavingsPlanArgs to keep cyclomatic complexity under the repo's gocyclo gate. --- mcp/tools/aws_savingsplans.go | 69 ++++++++++++++++++++++++------ mcp/tools/aws_savingsplans_test.go | 64 +++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 13 deletions(-) diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index b777ddb8d..ee1171310 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -18,7 +18,9 @@ const awsSavingsPlansPurchaseDescription = "Purchase an AWS Savings Plan (Comput "Database). THIS SPENDS REAL MONEY when dry_run=false and confirm=true. Always call with dry_run=true " + "first (the default) to validate your parameters before committing; a dry_run response never contacts AWS " + "and never spends money. Unlike RI purchases this is dollar-denominated: you specify hourly_commitment " + - "(USD/hour), not an instance count." + "(USD/hour), not an instance count. CAVEAT: sp_type=Database only supports term_years=1 and " + + "payment_option=no-upfront; AWS does not offer a 3-year Database Savings Plan or all-upfront/" + + "partial-upfront billing for it." // savingsPlansAccountLevelRegion is the region used to resolve the account- // level Savings Plans service client when the caller omits region -- Compute, @@ -107,27 +109,43 @@ func (t *awsSavingsPlansPurchaseTool) handle(ctx context.Context, _ *mcp.CallToo return nil, *resp, nil } -// savingsPlanRecommendationFromArgs validates args and builds the -// common.Recommendation to purchase, the effective region to resolve the -// service client against, and the effective dry_run/confirm booleans. -func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec common.Recommendation, region string, dryRun, confirm bool, err error) { +// validateSavingsPlanArgs validates every field of args that does not +// depend on the effective region, returning the typed sp_type, term, and +// payment_option. Split out of savingsPlanRecommendationFromArgs so that +// function's cyclomatic complexity stays under the repo's gocyclo gate as +// validation branches (e.g. validateDatabaseSPConstraints) are added. +func validateSavingsPlanArgs(args savingsPlansPurchaseArgs) (spType SPType, term TermYears, paymentOption PaymentOption, err error) { if args.HourlyCommitment <= 0 { - return common.Recommendation{}, "", false, false, fmt.Errorf("hourly_commitment must be > 0, got %v", args.HourlyCommitment) + return "", 0, "", fmt.Errorf("hourly_commitment must be > 0, got %v", args.HourlyCommitment) } - spType, err := ValidateSPType(args.SPType) + spType, err = ValidateSPType(args.SPType) if err != nil { - return common.Recommendation{}, "", false, false, err + return "", 0, "", err } - term, err := ValidateTermYears(args.TermYears) + term, err = ValidateTermYears(args.TermYears) if err != nil { - return common.Recommendation{}, "", false, false, err + return "", 0, "", err } - paymentOption, err := ValidatePaymentOption(args.PaymentOption) + paymentOption, err = ValidatePaymentOption(args.PaymentOption) if err != nil { - return common.Recommendation{}, "", false, false, err + return "", 0, "", err } if spType == SPTypeEC2Instance && args.Region == "" { - return common.Recommendation{}, "", false, false, fmt.Errorf("region is required for sp_type=%s", SPTypeEC2Instance) + return "", 0, "", fmt.Errorf("region is required for sp_type=%s", SPTypeEC2Instance) + } + if err := validateDatabaseSPConstraints(spType, term, paymentOption); err != nil { + return "", 0, "", err + } + return spType, term, paymentOption, nil +} + +// savingsPlanRecommendationFromArgs validates args and builds the +// common.Recommendation to purchase, the effective region to resolve the +// service client against, and the effective dry_run/confirm booleans. +func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec common.Recommendation, region string, dryRun, confirm bool, err error) { + spType, term, paymentOption, err := validateSavingsPlanArgs(args) + if err != nil { + return common.Recommendation{}, "", false, false, err } region = args.Region @@ -169,6 +187,31 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo return rec, region, dryRun, confirm, nil } +// validateDatabaseSPConstraints rejects a Database Savings Plan request +// AWS's purchase API would itself reject: per AWS's Database Savings Plans +// announcement (aws.amazon.com/about-aws/whats-new/2025/12/database-savings-plans-savings), +// Database Savings Plans support only a one-year term billed no-upfront -- +// unlike Compute, EC2Instance, and SageMaker plans, there is no three-year +// term and no all-upfront/partial-upfront option. Failing loud here, before +// building the recommendation, surfaces AWS's real constraint instead of +// letting a real purchase reach AWS only to be rejected there. +func validateDatabaseSPConstraints(spType SPType, term TermYears, paymentOption PaymentOption) error { + if spType != SPTypeDatabase { + return nil + } + if term != TermOneYear { + return fmt.Errorf("sp_type=%s only supports a %d-year term (got term_years=%d): "+ + "AWS Database Savings Plans do not offer a %d-year term", + SPTypeDatabase, TermOneYear, term, TermThreeYear) + } + if paymentOption != PaymentOptionNoUpfront { + return fmt.Errorf("sp_type=%s only supports payment_option=%s (got %q): "+ + "AWS Database Savings Plans do not offer all-upfront or partial-upfront billing", + SPTypeDatabase, PaymentOptionNoUpfront, paymentOption) + } + return nil +} + func (t *awsSavingsPlansPurchaseTool) resolveClient(args savingsPlansPurchaseArgs, region string, service common.ServiceType) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} diff --git a/mcp/tools/aws_savingsplans_test.go b/mcp/tools/aws_savingsplans_test.go index acf45be77..d95254e6b 100644 --- a/mcp/tools/aws_savingsplans_test.go +++ b/mcp/tools/aws_savingsplans_test.go @@ -81,6 +81,70 @@ func TestSavingsPlanRecommendationFromArgsInvalid(t *testing.T) { } } +// TestSavingsPlanRecommendationFromArgsDatabaseConstraints proves the +// CodeRabbit-requested up-front validation: per AWS's Database Savings +// Plans announcement, sp_type=Database only supports a one-year term +// billed no-upfront -- unlike Compute/EC2Instance/SageMaker, there is no +// 3-year term and no all-upfront/partial-upfront option. A mismatched +// term_years or payment_option must be rejected before building the +// recommendation, not left for AWS's purchase API to reject. +func TestSavingsPlanRecommendationFromArgsDatabaseConstraints(t *testing.T) { + t.Parallel() + cases := []struct { + name string + mutate func(*savingsPlansPurchaseArgs) + errSub string + }{ + {"3yr term rejected", func(a *savingsPlansPurchaseArgs) { a.TermYears = 3 }, "only supports a 1-year term"}, + {"all-upfront rejected", func(a *savingsPlansPurchaseArgs) { a.PaymentOption = "all-upfront" }, "only supports payment_option=no-upfront"}, + {"partial-upfront rejected", func(a *savingsPlansPurchaseArgs) { a.PaymentOption = "partial-upfront" }, "only supports payment_option=no-upfront"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := validSavingsPlansArgs() + args.SPType = "Database" + args.TermYears = 1 + args.PaymentOption = "no-upfront" + tc.mutate(&args) + _, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +// TestSavingsPlanRecommendationFromArgsDatabaseAllowedCombo proves the one +// term/payment_option combination Database Savings Plans actually support +// still succeeds. +func TestSavingsPlanRecommendationFromArgsDatabaseAllowedCombo(t *testing.T) { + t.Parallel() + args := validSavingsPlansArgs() + args.SPType = "Database" + args.TermYears = 1 + args.PaymentOption = "no-upfront" + + rec, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, common.ServiceSavingsPlansDatabase, rec.Service) + assert.Equal(t, "1yr", rec.Term) +} + +// TestSavingsPlanRecommendationFromArgsNonDatabaseUnaffected proves the +// Database-only constraint does not leak onto other sp_types: Compute keeps +// supporting 3-year all-upfront, the combo Database rejects. +func TestSavingsPlanRecommendationFromArgsNonDatabaseUnaffected(t *testing.T) { + t.Parallel() + args := validSavingsPlansArgs() + args.SPType = "Compute" + args.TermYears = 3 + args.PaymentOption = "all-upfront" + + rec, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "3yr", rec.Term) + assert.Equal(t, "all-upfront", rec.PaymentOption) +} + func TestAWSSavingsPlansPurchaseHandleConfirmFalseRefuses(t *testing.T) { t.Parallel() resolveCalled := false From a71597916ce1d4a544e95e72b9ef22be335b340a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:36:09 +0200 Subject: [PATCH 20/83] fix(mcp): properly case product names in simple RI tool descriptions t.spec.product ("opensearch", "redshift", "memorydb") was interpolated raw into the human-readable tool description, producing "AWS opensearch Reserved Instances" instead of "AWS OpenSearch Reserved Instances". Add a displayName field to simpleAWSRIPurchaseSpec for the description text only; the lowercase product identifier used for the Descriptor and API calls is unchanged. --- mcp/tools/aws_simple_ri.go | 6 +++++- mcp/tools/aws_simple_ri_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index 70b786c93..cae1619a8 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -20,6 +20,7 @@ import ( type simpleAWSRIPurchaseSpec struct { name string product string + displayName string // human-readable product name for the tool description, e.g. "OpenSearch" service common.ServiceType resourceTypeDesc string // jsonschema description for the resource_type field examplePrompts []string @@ -53,6 +54,7 @@ func NewAWSOpenSearchRIPurchaseTool() Registration { return newSimpleAWSRIPurchaseTool(simpleAWSRIPurchaseSpec{ name: "cudly_aws_opensearch_ri_purchase", product: "opensearch", + displayName: "OpenSearch", service: common.ServiceOpenSearch, resourceTypeDesc: "OpenSearch instance type, e.g. r6g.large.search", examplePrompts: []string{ @@ -67,6 +69,7 @@ func NewAWSRedshiftRIPurchaseTool() Registration { return newSimpleAWSRIPurchaseTool(simpleAWSRIPurchaseSpec{ name: "cudly_aws_redshift_ri_purchase", product: "redshift", + displayName: "Redshift", service: common.ServiceRedshift, resourceTypeDesc: "Redshift node type, e.g. dc2.large", examplePrompts: []string{ @@ -80,6 +83,7 @@ func NewAWSMemoryDBRIPurchaseTool() Registration { return newSimpleAWSRIPurchaseTool(simpleAWSRIPurchaseSpec{ name: "cudly_aws_memorydb_ri_purchase", product: "memorydb", + displayName: "MemoryDB", service: common.ServiceMemoryDB, resourceTypeDesc: "MemoryDB node type, e.g. db.r6g.large", examplePrompts: []string{ @@ -98,7 +102,7 @@ func (t *simpleAWSRIPurchaseTool) Descriptor() Descriptor { "Purchase AWS %s Reserved Instances. THIS SPENDS REAL MONEY when dry_run=false and confirm=true. "+ "Always call with dry_run=true first (the default) to validate your parameters before "+ "committing; a dry_run response never contacts AWS and never spends money.", - t.spec.product), + t.spec.displayName), RealPurchaseEnabled: true, ExamplePrompts: t.spec.examplePrompts, } diff --git a/mcp/tools/aws_simple_ri_test.go b/mcp/tools/aws_simple_ri_test.go index 0c1a0932b..6b1fa11f0 100644 --- a/mcp/tools/aws_simple_ri_test.go +++ b/mcp/tools/aws_simple_ri_test.go @@ -45,6 +45,31 @@ func TestSimpleAWSRIPurchaseDescriptorsAreDistinctAndRealPurchaseEnabled(t *test } } +// TestSimpleAWSRIPurchaseDescriptorUsesProperlyCasedDisplayName proves the +// CodeRabbit finding: t.spec.product ("opensearch", "redshift", "memorydb") +// used to be interpolated raw into the human-readable description, +// producing "AWS opensearch Reserved Instances" instead of the properly +// cased "AWS OpenSearch Reserved Instances". The identifier used in API +// calls (spec.product) must stay lowercase; only the description text uses +// the display name. +func TestSimpleAWSRIPurchaseDescriptorUsesProperlyCasedDisplayName(t *testing.T) { + t.Parallel() + wantDisplayName := map[string]string{ + "opensearch": "OpenSearch", + "redshift": "Redshift", + "memorydb": "MemoryDB", + } + for product, ctor := range simpleToolConstructors() { + t.Run(product, func(t *testing.T) { + d := ctor().Descriptor() + want := wantDisplayName[product] + require.NotEmpty(t, want, "test table missing a display name for %s", product) + assert.Contains(t, d.Description, want) + assert.NotContains(t, d.Description, "AWS "+product+" ", "description must not use the raw lowercase identifier") + }) + } +} + func TestSimpleAWSRIPurchaseRecommendationFromArgs(t *testing.T) { t.Parallel() for product, ctor := range simpleToolConstructors() { From f39fad1eaa286d1315cd2f1cb6726baf03c4f5dc Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:37:13 +0200 Subject: [PATCH 21/83] docs(mcp): use go install instead of a root build artifact `go build -o cudly-mcp` created an untracked root-level binary, which the repo's guidelines don't allow. Switch the install/launch instructions to `go install ./cmd/cudly-mcp` and running `cudly-mcp` from PATH. --- mcp/README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 2a18e55af..aa39e99c9 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -9,16 +9,18 @@ Every purchase tool is dry-run by default (`dry_run=true`) and requires an expli From the repository root: ```bash -go build -o cudly-mcp ./cmd/cudly-mcp +go install ./cmd/cudly-mcp ``` -Or run directly without a separate build step: +This installs the `cudly-mcp` binary to `$(go env GOBIN)` (or `$(go env GOPATH)/bin` if `GOBIN` is unset); make sure that directory is on your `PATH` so `cudly-mcp` resolves without a full path. + +Or run directly without a separate install step: ```bash go run ./cmd/cudly-mcp ``` -There is no separate module or release artifact for `cudly-mcp` yet -- build it from a checkout of this repository. +There is no separate module or release artifact for `cudly-mcp` yet -- install it from a checkout of this repository. ## Configure credentials @@ -55,14 +57,14 @@ Pass `gcp_project_id` on a per-call basis to override the ambient project. ## Launch ```bash -./cudly-mcp +cudly-mcp ``` The server speaks MCP over stdio and logs diagnostics to stderr; it does not print anything to stdout other than protocol traffic, so it is safe to launch directly from an MCP client's process-spawning config (below) rather than through a wrapper script. ## Register with an MCP client -Add an entry to your client's MCP server config. For Claude Code, this is `~/.claude/mcp.json`: +Add an entry to your client's MCP server config. For Claude Code, this is `~/.claude/mcp.json`. Use the absolute path `go install` reported (`$(go env GOBIN)/cudly-mcp` or `$(go env GOPATH)/bin/cudly-mcp`) if the client does not inherit your shell's `PATH`: ```json { From a551df9ce3b07ec574bcc7c6b0410cb73f6572fa Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 15:28:48 +0200 Subject: [PATCH 22/83] fix(mcp): capture decidePurchaseMode error in azure RI purchase gate The all-upfront payment gate in azureComputeRecommendationFromArgs discarded decidePurchaseMode's error, tripping errcheck in CI. Capture it and require it to be nil before evaluating the gate, extracted into azureRealPurchaseRequiresAllUpfront to keep the function's cyclomatic complexity under the pre-commit gocyclo threshold. --- mcp/tools/azure_compute_ri.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index af039cf46..fd5de55a5 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -148,7 +148,7 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co // never be honored for real, and a call that's merely missing confirm // surfaces that error from ExecutePurchase's shared gate instead of this // Azure-specific one. - if mode, _ := decidePurchaseMode(dryRun, confirm); mode == modeExecute && paymentOption != PaymentOptionAllUpfront { + if azureRealPurchaseRequiresAllUpfront(dryRun, confirm, paymentOption) { return common.Recommendation{}, false, false, fmt.Errorf( "azure reserved instances only support all-upfront billing for a real purchase (got payment_option=%q): "+ "azure's purchase API has no billing-plan parameter and always bills upfront, so any other "+ @@ -170,6 +170,19 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co return rec, dryRun, confirm, nil } +// azureRealPurchaseRequiresAllUpfront reports whether the given dry_run and +// confirm flags would drive a real purchase (mode == modeExecute) for a +// paymentOption other than all-upfront, the one case +// azureComputeRecommendationFromArgs must reject (see its doc comment). When +// decidePurchaseMode itself refuses the call (dry_run=false, confirm=false), +// gateErr is non-nil and this reports false: that refusal already surfaces +// from ExecutePurchase's shared gate, so this Azure-specific check must not +// also report a (misleading) all-upfront violation for it. +func azureRealPurchaseRequiresAllUpfront(dryRun, confirm bool, paymentOption PaymentOption) bool { + mode, gateErr := decidePurchaseMode(dryRun, confirm) + return gateErr == nil && mode == modeExecute && paymentOption != PaymentOptionAllUpfront +} + func (t *azureComputeRIPurchaseTool) resolveClient(args azureComputeRIPurchaseArgs) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { cfg := &provider.ProviderConfig{Name: string(common.ProviderAzure), AzureSubscriptionID: args.AzureSubscriptionID, Region: args.Region} From 7ee9e9b6be6d911f642113299639da4358c3ff9d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 15:29:08 +0200 Subject: [PATCH 23/83] docs: link to MCP server docs from root README Add a short pointer section so the root README surfaces the MCP server alongside the CLI reference instead of leaving it undiscovered. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index ab93c2ae2..49e52c195 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,12 @@ Topic pages: - [Purchase Safety](docs/cli/purchase-safety.md) - dry-run, audit log, idempotency window, and guardrails - [Cloud Setup](docs/cli/cloud-setup.md) - `configure-azure` and `configure-gcp` self-hosted credential bootstrap +## MCP Server + +CUDly also ships an MCP server (`cudly-mcp`) that lets Claude and other MCP clients search recommendations and drive RI, Savings Plan, and CUD purchases across AWS, Azure, and GCP, with the same dry-run-by-default safety as the CLI. + +Setup and usage: [mcp/README.md](mcp/README.md) + ## Key Features - **Multi-Cloud Support** - Unified interface for AWS (production), Azure (experimental), and GCP (experimental) From 60f4579a41b98143ee2fea1edbbcd762c3ec56d7 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 15:52:42 +0200 Subject: [PATCH 24/83] fix(azure): wire payment option into reservation billing plan buildReservationBody never set properties.billingPlan, so every Azure VM reservation purchase defaulted to Azure's Upfront billing regardless of the requested payment option. Add BillingPlanForPaymentOption, mapping all-upfront/upfront to armreservations.ReservationBillingPlanUpfront and no-upfront/monthly to ReservationBillingPlanMonthly (Azure's only two billing plans; no premium for spreading payments). Empty or unrecognized values, including partial-upfront which Azure cannot express, are a hard error rather than a silent default. --- providers/azure/services/compute/client.go | 5 ++ .../azure/services/compute/client_test.go | 84 +++++++++++++++---- .../internal/reservations/purchase.go | 37 ++++++++ .../internal/reservations/purchase_test.go | 38 +++++++++ 4 files changed, 150 insertions(+), 14 deletions(-) diff --git a/providers/azure/services/compute/client.go b/providers/azure/services/compute/client.go index fd447817b..765ff55d6 100644 --- a/providers/azure/services/compute/client.go +++ b/providers/azure/services/compute/client.go @@ -416,12 +416,17 @@ func (c *ComputeClient) buildReservationBody(rec common.Recommendation, source, if err != nil { return nil, err } + billingPlan, err := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + if err != nil { + return nil, err + } requestBody := map[string]interface{}{ "sku": map[string]string{"name": rec.ResourceType}, "location": c.region, "properties": map[string]interface{}{ "reservedResourceType": string(armreservations.ReservedResourceTypeVirtualMachines), "billingScopeId": fmt.Sprintf("/subscriptions/%s", c.subscriptionID), + "billingPlan": string(billingPlan), "term": fmt.Sprintf("P%dY", termYears), "quantity": rec.Count, "displayName": reservations.BuildDisplayName(reservations.DisplayNameFields{ diff --git a/providers/azure/services/compute/client_test.go b/providers/azure/services/compute/client_test.go index 97862ea94..feb89879c 100644 --- a/providers/azure/services/compute/client_test.go +++ b/providers/azure/services/compute/client_test.go @@ -633,6 +633,7 @@ func TestComputeClient_PurchaseCommitment_Success(t *testing.T) { Term: "1yr", Count: 1, CommitmentCost: 2000.0, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -662,6 +663,7 @@ func TestComputeClient_PurchaseCommitment_3YearTerm(t *testing.T) { Term: "3yr", Count: 1, CommitmentCost: 5000.0, + PaymentOption: "all-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -690,6 +692,7 @@ func TestComputeClient_PurchaseCommitment_Accepted(t *testing.T) { Term: "1yr", Count: 1, CommitmentCost: 2000.0, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -705,9 +708,10 @@ func TestComputeClient_PurchaseCommitment_TokenError(t *testing.T) { client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) rec := common.Recommendation{ - ResourceType: "Standard_D2s_v3", - Term: "1yr", - Count: 1, + ResourceType: "Standard_D2s_v3", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -729,9 +733,10 @@ func TestComputeClient_PurchaseCommitment_HTTPError(t *testing.T) { })).Return(nil, errors.New("network error")).Once() rec := common.Recommendation{ - ResourceType: "Standard_D2s_v3", - Term: "1yr", - Count: 1, + ResourceType: "Standard_D2s_v3", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -759,9 +764,10 @@ func TestComputeClient_PurchaseCommitment_BadStatus(t *testing.T) { ).Once() rec := common.Recommendation{ - ResourceType: "Standard_D2s_v3", - Term: "1yr", - Count: 1, + ResourceType: "Standard_D2s_v3", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -800,6 +806,7 @@ func TestComputeClient_PurchaseCommitment_TwoStepFlow(t *testing.T) { Term: "1yr", Count: 1, CommitmentCost: 500.0, + PaymentOption: "all-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -842,7 +849,7 @@ func TestComputeClient_PurchaseCommitment_SessionTimeoutRetry(t *testing.T) { return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/order-second/purchase" })).Return(mocks.CreateMockHTTPResponse(http.StatusOK, `{}`), nil).Once() - rec := common.Recommendation{ResourceType: "Standard_B2ats_v2", Term: "1yr", Count: 1} + rec := common.Recommendation{ResourceType: "Standard_B2ats_v2", Term: "1yr", Count: 1, PaymentOption: "no-upfront"} result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.NoError(t, err) assert.True(t, result.Success) @@ -881,7 +888,7 @@ func TestComputeClient_PurchaseCommitment_TagInjection(t *testing.T) { r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" })).Return(mocks.CreateMockHTTPResponse(http.StatusOK, `{}`), nil).Once() - rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Term: "1yr", Count: 1, CommitmentCost: 2000.0} + rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Term: "1yr", Count: 1, CommitmentCost: 2000.0, PaymentOption: "monthly"} result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: source}) require.NoError(t, err) assert.True(t, result.Success) @@ -990,9 +997,57 @@ func TestFetchAzurePricing_WrapperSmokeTest(t *testing.T) { assert.Equal(t, "Standard_D2s_v3", result.Items[0].ArmSKUName) } +// TestBuildReservationBody_BillingPlan pins the billingPlan wiring: Azure +// reservations support exactly two billing plans (Upfront, Monthly -- no +// partial-upfront), and rec.PaymentOption must map onto the correct one +// regardless of which vocabulary populated it (the converter's +// "upfront"/"monthly" or the CLI/MCP's "all-upfront"/"no-upfront"). An empty +// or unrecognized value (including "partial-upfront", which Azure cannot +// express) must fail loud rather than silently defaulting to Upfront +// (feedback_no_silent_fallbacks) -- before this fix, buildReservationBody +// never set billingPlan at all, so every purchase silently defaulted to +// Azure's Upfront behavior regardless of what the caller requested. +func TestBuildReservationBody_BillingPlan(t *testing.T) { + cases := []struct { + name string + paymentOption string + wantPlan string + wantErrSub string + }{ + {name: "all-upfront maps to Upfront", paymentOption: "all-upfront", wantPlan: "Upfront"}, + {name: "upfront maps to Upfront", paymentOption: "upfront", wantPlan: "Upfront"}, + {name: "no-upfront maps to Monthly", paymentOption: "no-upfront", wantPlan: "Monthly"}, + {name: "monthly maps to Monthly", paymentOption: "monthly", wantPlan: "Monthly"}, + {name: "partial-upfront is rejected", paymentOption: "partial-upfront", wantErrSub: "partial-upfront has no azure equivalent"}, + {name: "empty payment option is rejected", paymentOption: "", wantErrSub: "azure reservations support only upfront or monthly billing"}, + {name: "unrecognized payment option is rejected", paymentOption: "bogus", wantErrSub: "azure reservations support only upfront or monthly billing"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := &ComputeClient{region: "eastus", subscriptionID: "sub-abc"} + rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr", PaymentOption: tc.paymentOption} + + body, err := c.buildReservationBody(rec, common.PurchaseSourceWeb, "") + + if tc.wantErrSub != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrSub) + assert.Nil(t, body) + return + } + require.NoError(t, err) + var got map[string]interface{} + require.NoError(t, json.Unmarshal(body, &got)) + props, ok := got["properties"].(map[string]interface{}) + require.True(t, ok, "properties map missing from reservation body") + assert.Equal(t, tc.wantPlan, props["billingPlan"]) + }) + } +} + func TestBuildReservationBody_IncludesPurchaseAutomationTag(t *testing.T) { c := &ComputeClient{region: "eastus", subscriptionID: "sub-abc"} - rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr"} + rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr", PaymentOption: "no-upfront"} body, err := c.buildReservationBody(rec, common.PurchaseSourceWeb, "") require.NoError(t, err) @@ -1006,7 +1061,7 @@ func TestBuildReservationBody_IncludesPurchaseAutomationTag(t *testing.T) { func TestBuildReservationBody_OmitsTagsWhenSourceAndTokenEmpty(t *testing.T) { c := &ComputeClient{region: "eastus", subscriptionID: "sub-abc"} - rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr"} + rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr", PaymentOption: "no-upfront"} body, err := c.buildReservationBody(rec, "", "") require.NoError(t, err) @@ -1024,7 +1079,7 @@ func TestBuildReservationBody_OmitsTagsWhenSourceAndTokenEmpty(t *testing.T) { // and skip the duplicate buy. func TestBuildReservationBody_IncludesIdempotencyTokenTag(t *testing.T) { c := &ComputeClient{region: "eastus", subscriptionID: "sub-abc"} - rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr"} + rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr", PaymentOption: "no-upfront"} token := common.DeriveIdempotencyToken("exec-721-compute", 0) body, err := c.buildReservationBody(rec, common.PurchaseSourceWeb, token) @@ -1280,6 +1335,7 @@ func TestComputeClient_PurchaseCommitment_DisplayNameConformsToAzureAllowlist(t Term: "1yr", Count: 1, CommitmentCost: 2000.0, + PaymentOption: "all-upfront", } _, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.NoError(t, err) diff --git a/providers/azure/services/internal/reservations/purchase.go b/providers/azure/services/internal/reservations/purchase.go index 7bf666e69..c868761ce 100644 --- a/providers/azure/services/internal/reservations/purchase.go +++ b/providers/azure/services/internal/reservations/purchase.go @@ -49,6 +49,8 @@ import ( "strings" "time" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations" + "github.com/LeanerCloud/CUDly/pkg/common" ) @@ -70,6 +72,41 @@ func ParseTermYears(term string) (int, error) { } } +// BillingPlanForPaymentOption maps a recommendation's payment-option string +// to the armreservations.ReservationBillingPlan Azure's purchase API expects +// in properties.billingPlan (confirmed against +// armreservations.PurchaseRequestProperties.BillingPlan and the two-member +// enum in constants.go: ReservationBillingPlanUpfront = "Upfront", +// ReservationBillingPlanMonthly = "Monthly"). Azure reservations support +// exactly those two billing plans -- there is no partial-upfront -- and +// Monthly costs the same total as Upfront (no premium for spreading +// payments), so "no-upfront"/"monthly" is a safe, cost-neutral default at +// the caller layer. +// +// CUDly's own recommendation converter (providers/azure/internal/ +// recommendations/converter.go) emits PaymentOption "upfront"/"monthly"; +// the MCP tool boundary and the CLI --payment flag use "all-upfront"/ +// "no-upfront"/"partial-upfront". Both vocabularies are accepted here so +// this function is the single mapping point regardless of which caller +// populated rec.PaymentOption. +// +// An empty or unrecognized value (including "partial-upfront", which Azure +// cannot express at all) is a hard error rather than a silent default: the +// default belongs at the caller (CLI/MCP) layer, never silently applied on +// this money-affecting path (feedback_no_silent_fallbacks). +func BillingPlanForPaymentOption(paymentOption string) (armreservations.ReservationBillingPlan, error) { + switch strings.ToLower(strings.TrimSpace(paymentOption)) { + case "all-upfront", "upfront": + return armreservations.ReservationBillingPlanUpfront, nil + case "no-upfront", "monthly": + return armreservations.ReservationBillingPlanMonthly, nil + default: + return "", fmt.Errorf( + "azure reservations support only upfront or monthly billing; %q is not available (partial-upfront has no azure equivalent)", + paymentOption) + } +} + // apiVersion is the GA api-version for the Microsoft.Capacity Reservations API. // Pinned to 2022-11-01 — the last stable version before Azure introduced the // calculatePrice requirement for new SKU families. diff --git a/providers/azure/services/internal/reservations/purchase_test.go b/providers/azure/services/internal/reservations/purchase_test.go index e1a112148..e62abd98c 100644 --- a/providers/azure/services/internal/reservations/purchase_test.go +++ b/providers/azure/services/internal/reservations/purchase_test.go @@ -8,6 +8,7 @@ import ( "net/http" "testing" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -710,3 +711,40 @@ func TestParseTermYears(t *testing.T) { } } } + +// TestBillingPlanForPaymentOption pins the two-billing-plan Azure contract: +// Upfront and Monthly are the only members of armreservations. +// ReservationBillingPlan (constants.go), and Monthly costs the same total +// as Upfront (no partial-upfront exists). Both the converter's +// "upfront"/"monthly" vocabulary and the CLI/MCP's "all-upfront"/ +// "no-upfront" vocabulary must map onto the same two SDK enum values; every +// other input (including "partial-upfront", empty, and unrecognized +// strings) must be a hard error, never a silent default +// (feedback_no_silent_fallbacks). +func TestBillingPlanForPaymentOption(t *testing.T) { + tests := []struct { + paymentOption string + want armreservations.ReservationBillingPlan + wantErr bool + }{ + {"all-upfront", armreservations.ReservationBillingPlanUpfront, false}, + {"upfront", armreservations.ReservationBillingPlanUpfront, false}, + {"ALL-UPFRONT", armreservations.ReservationBillingPlanUpfront, false}, // case-insensitive + {" upfront ", armreservations.ReservationBillingPlanUpfront, false}, // whitespace-tolerant + {"no-upfront", armreservations.ReservationBillingPlanMonthly, false}, + {"monthly", armreservations.ReservationBillingPlanMonthly, false}, + {"partial-upfront", "", true}, // Azure has no partial-upfront equivalent + {"", "", true}, // empty must error, never silently default to Upfront + {"bogus", "", true}, + } + for _, tc := range tests { + got, err := BillingPlanForPaymentOption(tc.paymentOption) + if tc.wantErr { + assert.Error(t, err, "payment_option=%q should be an error", tc.paymentOption) + assert.Empty(t, got, "payment_option=%q error return should be empty", tc.paymentOption) + } else { + require.NoError(t, err, "payment_option=%q should not error", tc.paymentOption) + assert.Equal(t, tc.want, got, "payment_option=%q", tc.paymentOption) + } + } +} From 4e90b9c40ec08e4b555abe91afa61f7abcdcad4c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 15:53:18 +0200 Subject: [PATCH 25/83] fix(mcp): accept and default to no-upfront on Azure RI purchase tool Now that buildReservationBody honors billingPlan, drop the all-upfront-only rejection on cudly_azure_compute_ri_purchase: payment_option defaults to no-upfront (matching the CLI's --payment default) when omitted, and both all-upfront and no-upfront flow through to a real purchase. partial-upfront is still rejected, unconditionally, since Azure has no equivalent billing plan at any layer. --- mcp/tools/azure_compute_ri.go | 95 +++++++--------- mcp/tools/azure_compute_ri_test.go | 168 +++++++++++++++++++++-------- 2 files changed, 161 insertions(+), 102 deletions(-) diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index fd5de55a5..b6ea9f8fa 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -12,38 +12,33 @@ import ( const azureComputeRIPurchaseName = "cudly_azure_compute_ri_purchase" -// azureComputeRIPurchaseDescription flags a real, pre-existing gap in -// Azure's purchase API (found while wiring this tool, not introduced by it): -// Azure's purchase body (providers/azure/services/compute/client.go: -// buildReservationBody) never sends a billingPlanType, so every purchase -// uses Azure's default (upfront) billing plan regardless of the -// payment_option requested here. Rather than silently accepting a -// payment_option Azure will not actually honor -- and purchasing under a -// different billing schedule than the caller chose -- this tool requires -// payment_option=all-upfront for a real purchase (dry_run=false, -// confirm=true) and rejects any other value there with an explicit error -// (fail loud, not a silent mismatch; see azureComputeRecommendationFromArgs). -// A dry_run=true preview still validates other payment_option values so a -// caller can rehearse parameters without hitting this rejection. +// azureComputeRIPurchaseDescription documents Azure's actual billing-plan +// contract: providers/azure/services/compute/client.go's buildReservationBody +// sends properties.billingPlan (armreservations.ReservationBillingPlan -- +// Upfront or Monthly), so both all-upfront and no-upfront purchases are +// honored for real. Monthly costs the same total as Upfront -- Azure has no +// premium for spreading payments -- but there is no partial-upfront billing +// plan at all, so that value is rejected with an explicit error rather than +// silently purchased under a different schedule (see +// azureComputeRecommendationFromArgs). const azureComputeRIPurchaseDescription = "Purchase an Azure VM Reserved Instance. THIS SPENDS REAL MONEY when " + "dry_run=false and confirm=true. Always call with dry_run=true first (the default) to validate your " + - "parameters before committing; a dry_run response never contacts Azure and never spends money. CAVEAT: " + - "Azure Reserved Instances only support all-upfront billing -- Azure's purchase API has no billing-plan " + - "parameter and always bills upfront -- so payment_option must be all-upfront for a real purchase " + - "(dry_run=false, confirm=true); any other value is rejected there with an error rather than silently " + - "purchased under a schedule Azure will not honor. A dry_run=true preview still validates other " + - "payment_option values so a caller can rehearse parameters before learning that." + "parameters before committing; a dry_run response never contacts Azure and never spends money. Azure " + + "Reserved Instances support two billing plans: all-upfront and no-upfront (billed monthly, same total " + + "price as all-upfront -- Azure charges no premium for spreading payments). payment_option defaults to " + + "no-upfront when omitted. Azure has no partial-upfront billing plan, so that value is rejected with an " + + "explicit error rather than silently purchased under all-upfront or no-upfront instead." // azureComputeRIPurchaseArgs is the input schema for // cudly_azure_compute_ri_purchase. Unlike EC2, Azure's purchase body needs no // Recommendation.Details -- providers/azure/services/compute/client.go's -// buildReservationBody only reads Region/ResourceType/Count/Term. +// buildReservationBody only reads Region/ResourceType/Count/Term/PaymentOption. type azureComputeRIPurchaseArgs struct { Region string `json:"region" jsonschema:"Azure region, e.g. eastus"` VMSize string `json:"vm_size" jsonschema:"Azure VM size (SKU), e.g. Standard_D2s_v3"` Count int `json:"count" jsonschema:"number of VM instances to reserve, must be > 0"` TermYears int `json:"term_years" jsonschema:"commitment length in years"` - PaymentOption string `json:"payment_option" jsonschema:"payment schedule; Azure only honors all-upfront, see the CAVEAT in this tool's description"` + 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"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` @@ -76,7 +71,7 @@ func (t *azureComputeRIPurchaseTool) Descriptor() Descriptor { func (t *azureComputeRIPurchaseTool) Register(s *mcp.Server) error { schema, err := BuildInputSchema[azureComputeRIPurchaseArgs](map[string]FieldOverride{ "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, - "payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionPartialUpfront), string(PaymentOptionNoUpfront)}}, + "payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionPartialUpfront), string(PaymentOptionNoUpfront)}, Default: string(PaymentOptionNoUpfront)}, "dry_run": {Default: true}, "confirm": {Default: false}, }) @@ -124,10 +119,32 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co if err != nil { return common.Recommendation{}, false, false, err } - paymentOption, err := ValidatePaymentOption(args.PaymentOption) + + // payment_option defaults to no-upfront (matching the CLI's --payment + // default, cmd/main.go) when the caller omits it -- an omitted string + // field arrives as "" and is never confused with an explicit, + // unrecognized value (feedback_no_silent_fallbacks: the default is + // applied here, explicitly, not fabricated deeper in the stack). + paymentOptionStr := args.PaymentOption + if paymentOptionStr == "" { + paymentOptionStr = string(PaymentOptionNoUpfront) + } + paymentOption, err := ValidatePaymentOption(paymentOptionStr) if err != nil { return common.Recommendation{}, false, false, err } + // Azure reservations support exactly two billing plans (Upfront, + // Monthly -- see providers/azure/services/internal/reservations. + // BillingPlanForPaymentOption); there is no partial-upfront at any + // layer of Azure's API. Rejecting it here, unconditionally (not just + // for a real purchase), means a dry_run preview never reports success + // for a request that could never be honored for real. + if paymentOption == PaymentOptionPartialUpfront { + return common.Recommendation{}, false, false, fmt.Errorf( + "azure reservations do not support payment_option=%q: azure billing plans are all-upfront or "+ + "no-upfront (monthly, same total price) only, with no partial-upfront option", + paymentOption) + } dryRun, confirm = true, false if args.DryRun != nil { @@ -137,25 +154,6 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co confirm = *args.Confirm } - // Azure's purchase API has no billing-plan parameter and always bills - // upfront (see the azureComputeRIPurchaseDescription doc comment), so a - // payment_option other than all-upfront would be silently purchased - // under a schedule the caller never chose. Reusing decidePurchaseMode - // (the same real-purchase-vs-preview gate ExecutePurchase applies) scopes - // this rejection to a call that would actually spend money: a preview - // (dry_run=true) still validates every other parameter so a caller can - // rehearse a no-upfront/partial-upfront request before learning it can - // never be honored for real, and a call that's merely missing confirm - // surfaces that error from ExecutePurchase's shared gate instead of this - // Azure-specific one. - if azureRealPurchaseRequiresAllUpfront(dryRun, confirm, paymentOption) { - return common.Recommendation{}, false, false, fmt.Errorf( - "azure reserved instances only support all-upfront billing for a real purchase (got payment_option=%q): "+ - "azure's purchase API has no billing-plan parameter and always bills upfront, so any other "+ - "payment_option would be purchased under a different schedule than requested rather than honored", - paymentOption) - } - rec = common.Recommendation{ Provider: common.ProviderAzure, Service: common.ServiceCompute, @@ -170,19 +168,6 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co return rec, dryRun, confirm, nil } -// azureRealPurchaseRequiresAllUpfront reports whether the given dry_run and -// confirm flags would drive a real purchase (mode == modeExecute) for a -// paymentOption other than all-upfront, the one case -// azureComputeRecommendationFromArgs must reject (see its doc comment). When -// decidePurchaseMode itself refuses the call (dry_run=false, confirm=false), -// gateErr is non-nil and this reports false: that refusal already surfaces -// from ExecutePurchase's shared gate, so this Azure-specific check must not -// also report a (misleading) all-upfront violation for it. -func azureRealPurchaseRequiresAllUpfront(dryRun, confirm bool, paymentOption PaymentOption) bool { - mode, gateErr := decidePurchaseMode(dryRun, confirm) - return gateErr == nil && mode == modeExecute && paymentOption != PaymentOptionAllUpfront -} - func (t *azureComputeRIPurchaseTool) resolveClient(args azureComputeRIPurchaseArgs) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { cfg := &provider.ProviderConfig{Name: string(common.ProviderAzure), AzureSubscriptionID: args.AzureSubscriptionID, Region: args.Region} diff --git a/mcp/tools/azure_compute_ri_test.go b/mcp/tools/azure_compute_ri_test.go index 1d34556b2..4ff5f8b68 100644 --- a/mcp/tools/azure_compute_ri_test.go +++ b/mcp/tools/azure_compute_ri_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -11,11 +12,11 @@ import ( "github.com/LeanerCloud/CUDly/pkg/provider" ) -// validAzureComputeArgs uses payment_option=all-upfront, the only schedule -// Azure Reserved Instances actually honor (see -// TestAzureComputeRecommendationFromArgsRejectsUnhonoredPaymentOption): -// azure_compute_ri.go rejects any other value rather than silently -// purchasing under a mismatched billing schedule. +// validAzureComputeArgs uses payment_option=all-upfront. Azure Reserved +// Instances honor both all-upfront and no-upfront (see +// TestAzureComputeRecommendationFromArgsAcceptsNoUpfront); all-upfront is +// used here just to keep the baseline args deterministic across tests that +// don't care which honored schedule they exercise. func validAzureComputeArgs() azureComputeRIPurchaseArgs { return azureComputeRIPurchaseArgs{ Region: "eastus", @@ -64,61 +65,73 @@ func TestAzureComputeRecommendationFromArgsInvalid(t *testing.T) { } } -// TestAzureComputeRecommendationFromArgsRejectsUnhonoredPaymentOption proves -// finding 3 of the adversarial review: azure_compute_ri.go used to validate -// payment_option against the shared AWS/Azure/GCP enum and then silently -// ignore it -- Azure's purchase API has no billing-plan parameter and always -// bills upfront, so a caller requesting no-upfront or partial-upfront got a -// real purchase billed upfront, under a payment schedule they never chose. -// A call that would actually execute (dry_run=false, confirm=true) must -// reject any payment_option other than all-upfront with an explicit error -// instead of silently mismatching. -func TestAzureComputeRecommendationFromArgsRejectsUnhonoredPaymentOption(t *testing.T) { +// TestAzureComputeRecommendationFromArgsRejectsPartialUpfront proves Azure's +// billing-plan contract has exactly two members (Upfront, Monthly -- see +// providers/azure/services/internal/reservations.BillingPlanForPaymentOption): +// partial-upfront has no Azure equivalent at any layer, so it must be +// rejected with an explicit error rather than silently purchased under +// all-upfront or no-upfront instead. Unlike the former all-upfront-only gate +// (removed once billingPlan wiring landed), this rejection is unconditional: +// it fires for a dry_run preview too, because Azure can never honor +// partial-upfront for real, not just a gap in this tool's own behavior. +func TestAzureComputeRecommendationFromArgsRejectsPartialUpfront(t *testing.T) { t.Parallel() - for _, po := range []string{"no-upfront", "partial-upfront"} { - t.Run(po, func(t *testing.T) { + for _, dryRun := range []bool{true, false} { + t.Run(fmt.Sprintf("dry_run=%v", dryRun), func(t *testing.T) { args := validAzureComputeArgs() - args.PaymentOption = po - args.DryRun = boolPtr(false) + args.PaymentOption = "partial-upfront" + args.DryRun = boolPtr(dryRun) args.Confirm = boolPtr(true) _, _, _, err := azureComputeRecommendationFromArgs(args) require.Error(t, err) - assert.Contains(t, err.Error(), "all-upfront") + assert.Contains(t, err.Error(), "partial-upfront") + assert.Contains(t, err.Error(), "no-upfront") }) } } -// TestAzureComputeRecommendationFromArgsPreviewAllowsUnhonoredPaymentOption -// proves the CodeRabbit-requested scoping: a dry_run=true preview never -// spends money, so it must still validate a no-upfront/partial-upfront -// payment_option instead of rejecting it outright -- the caller learns -// about the all-upfront-only constraint from the tool description and the -// real-purchase rejection, not from being blocked at preview time. -func TestAzureComputeRecommendationFromArgsPreviewAllowsUnhonoredPaymentOption(t *testing.T) { +// TestAzureComputeRecommendationFromArgsAcceptsAllUpfront proves the +// all-upfront billing plan is honored for real (dry_run=false, confirm=true). +func TestAzureComputeRecommendationFromArgsAcceptsAllUpfront(t *testing.T) { t.Parallel() - for _, po := range []string{"no-upfront", "partial-upfront"} { - t.Run(po, func(t *testing.T) { - args := validAzureComputeArgs() - args.PaymentOption = po - args.DryRun = boolPtr(true) - rec, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) - require.NoError(t, err) - assert.True(t, dryRun) - assert.False(t, confirm) - assert.Equal(t, po, rec.PaymentOption) - }) - } + args := validAzureComputeArgs() + args.PaymentOption = "all-upfront" + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + rec, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) + require.NoError(t, err) + assert.False(t, dryRun) + assert.True(t, confirm) + assert.Equal(t, "all-upfront", rec.PaymentOption) } -// TestAzureComputeRecommendationFromArgsAcceptsAllUpfront proves the one -// payment_option Azure actually honors still succeeds. -func TestAzureComputeRecommendationFromArgsAcceptsAllUpfront(t *testing.T) { +// TestAzureComputeRecommendationFromArgsAcceptsNoUpfront proves the +// no-upfront billing plan (armreservations.ReservationBillingPlanMonthly) is +// honored for real, not just at preview time -- the gap this PR closes. +func TestAzureComputeRecommendationFromArgsAcceptsNoUpfront(t *testing.T) { t.Parallel() args := validAzureComputeArgs() - args.PaymentOption = "all-upfront" + args.PaymentOption = "no-upfront" + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + rec, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) + require.NoError(t, err) + assert.False(t, dryRun) + assert.True(t, confirm) + assert.Equal(t, "no-upfront", rec.PaymentOption) +} + +// TestAzureComputeRecommendationFromArgsDefaultsToNoUpfront proves omitting +// payment_option defaults to no-upfront (matching the CLI's --payment +// default, cmd/main.go), not to Azure's raw API default (all-upfront) and +// not to an error. +func TestAzureComputeRecommendationFromArgsDefaultsToNoUpfront(t *testing.T) { + t.Parallel() + args := validAzureComputeArgs() + args.PaymentOption = "" rec, _, _, err := azureComputeRecommendationFromArgs(args) require.NoError(t, err) - assert.Equal(t, "all-upfront", rec.PaymentOption) + assert.Equal(t, "no-upfront", rec.PaymentOption) } func TestAzureComputeRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { @@ -140,7 +153,10 @@ func TestAzureComputeRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { assert.Contains(t, err.Error(), "confirm=true") } -func TestAzureComputeRIPurchaseHandleDryRunAllowsUnhonoredPaymentOption(t *testing.T) { +// TestAzureComputeRIPurchaseHandleDryRunAcceptsNoUpfront proves a preview +// validates and accepts no-upfront (it is now an honored billing plan, not +// merely tolerated at preview time). +func TestAzureComputeRIPurchaseHandleDryRunAcceptsNoUpfront(t *testing.T) { t.Parallel() resolveCalled := false tool := &azureComputeRIPurchaseTool{ @@ -159,7 +175,10 @@ func TestAzureComputeRIPurchaseHandleDryRunAllowsUnhonoredPaymentOption(t *testi assert.True(t, resp.DryRun) } -func TestAzureComputeRIPurchaseHandleRealPurchaseRejectsUnhonoredPaymentOption(t *testing.T) { +// TestAzureComputeRIPurchaseHandleRealPurchaseRejectsPartialUpfront proves +// the one payment_option Azure cannot express (partial-upfront) is still +// rejected for a real purchase after the billingPlan wiring landed. +func TestAzureComputeRIPurchaseHandleRealPurchaseRejectsPartialUpfront(t *testing.T) { t.Parallel() resolveCalled := false tool := &azureComputeRIPurchaseTool{ @@ -176,7 +195,62 @@ func TestAzureComputeRIPurchaseHandleRealPurchaseRejectsUnhonoredPaymentOption(t _, _, err := tool.handle(context.Background(), nil, args) require.Error(t, err) assert.False(t, resolveCalled) - assert.Contains(t, err.Error(), "all-upfront") + assert.Contains(t, err.Error(), "partial-upfront") +} + +// TestAzureComputeRIPurchaseHandleRealPurchaseNoUpfront proves a real +// purchase (dry_run=false, confirm=true) with payment_option=no-upfront +// reaches the provider -- the core gap this PR closes: before billingPlan +// wiring, only all-upfront could execute for real. +func TestAzureComputeRIPurchaseHandleRealPurchaseNoUpfront(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "azure-res-monthly"}} + tool := &azureComputeRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "azure"}, + client: fake, + gotService: new(common.ServiceType), + gotRegion: new(string), + }, nil + }, + } + args := validAzureComputeArgs() + args.PaymentOption = "no-upfront" + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, "no-upfront", fake.purchaseResult.Recommendation.PaymentOption) +} + +// TestAzureComputeRIPurchaseHandleOmittedPaymentOptionDefaultsToNoUpfront +// proves the tool-level default (payment_option omitted from the request) +// flows through the handler the same way an explicit no-upfront does. +func TestAzureComputeRIPurchaseHandleOmittedPaymentOptionDefaultsToNoUpfront(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "azure-res-default"}} + tool := &azureComputeRIPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "azure"}, + client: fake, + gotService: new(common.ServiceType), + gotRegion: new(string), + }, nil + }, + } + args := validAzureComputeArgs() + args.PaymentOption = "" + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, "no-upfront", fake.purchaseResult.Recommendation.PaymentOption) } func TestAzureComputeRIPurchaseHandleDryRunNeverCallsProvider(t *testing.T) { From 0206411d55d7d40f7231094ce3a6dfd8a08836c6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 15:53:42 +0200 Subject: [PATCH 26/83] docs(mcp): update Azure billing-plan caveat to reflect no-upfront support The Azure caveat still described the old all-upfront-only limitation; update it to note the two supported billing plans, the no-upfront default, and that partial-upfront remains unsupported. --- mcp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/README.md b/mcp/README.md index aa39e99c9..754e33055 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -104,7 +104,7 @@ Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_ These are pre-existing behaviours in the underlying purchase clients, not something introduced by or specific to the MCP server -- flagged here so you know what to expect: -- **Azure VM Reservations always bill upfront.** Azure's purchase API has no billing-plan parameter and always bills upfront, so `cudly_azure_compute_ri_purchase` requires `payment_option=all-upfront` and rejects any other value with an explicit error rather than silently purchasing under a schedule Azure would not honor. +- **Azure VM Reservations have no partial-upfront billing plan.** Azure honors exactly two billing plans, all-upfront and no-upfront (billed monthly, same total price -- Azure charges no premium for spreading payments), and `cudly_azure_compute_ri_purchase` defaults `payment_option` to no-upfront when omitted. `payment_option=partial-upfront` has no Azure equivalent and is rejected with an explicit error rather than silently purchased under all-upfront or no-upfront instead. - **GCP Compute Engine CUDs commit resources, not instances.** `cudly_gcp_computeengine_cud_purchase` takes `vcpu_count` and `memory_gb` directly (a CUD is a vCPU+memory commitment), not an instance count -- there is no implicit vCPU-per-instance conversion. ## Deployment model From ad523501e436fe41a2c9d68e3fa488d263754244 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:21:53 +0200 Subject: [PATCH 27/83] fix(mcp): register provider factories for cudly-mcp binary providers/aws, providers/azure, and providers/gcp register their factory via init() in the package root, and cmd/main.go already blank-imports all three so the CLI binary picks them up. cmd/cudly-mcp did not import them and nothing else in the mcp package's import graph pulled them in either, so provider.CreateProvider always returned "provider not registered" and every real purchase failed at the ResolveClient step. Only dry-run previews worked. Add the same three blank imports cmd/main.go already carries, and add a regression test in package main (mcp/... tests cannot observe this bug since that test binary never pulls in cmd/cudly-mcp's import graph). --- cmd/cudly-mcp/main.go | 3 ++ cmd/cudly-mcp/main_test.go | 103 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 cmd/cudly-mcp/main_test.go diff --git a/cmd/cudly-mcp/main.go b/cmd/cudly-mcp/main.go index 7817f266f..fde741f11 100644 --- a/cmd/cudly-mcp/main.go +++ b/cmd/cudly-mcp/main.go @@ -15,6 +15,9 @@ import ( gosdk "github.com/modelcontextprotocol/go-sdk/mcp" cudlymcp "github.com/LeanerCloud/CUDly/mcp" + _ "github.com/LeanerCloud/CUDly/providers/aws" + _ "github.com/LeanerCloud/CUDly/providers/azure" + _ "github.com/LeanerCloud/CUDly/providers/gcp" ) // version is overridable at build time via: diff --git a/cmd/cudly-mcp/main_test.go b/cmd/cudly-mcp/main_test.go new file mode 100644 index 000000000..1ec678940 --- /dev/null +++ b/cmd/cudly-mcp/main_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + gosdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cudlymcp "github.com/LeanerCloud/CUDly/mcp" +) + +// isolateFromAmbientAWS points the AWS SDK at deliberately nonexistent +// profile/config/credentials so config.LoadDefaultConfig cannot resolve any +// real credentials -- neither from a dev machine's ~/.aws files nor from the +// network (IMDS, ECS/EKS container credential endpoints, web identity). This +// is required for TestRealPurchasePastProviderRegistration below: that test +// drives a real (non-dry-run) purchase call, so it must be impossible for it +// to reach an actual AWS account or make an actual network call, in this or +// any other environment the test happens to run in. +func isolateFromAmbientAWS(t *testing.T) { + t.Helper() + t.Setenv("AWS_PROFILE", "cudly-mcp-regression-test-nonexistent-profile") + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(t.TempDir(), "no-credentials")) + t.Setenv("AWS_CONFIG_FILE", filepath.Join(t.TempDir(), "no-config")) + t.Setenv("AWS_ACCESS_KEY_ID", "") + t.Setenv("AWS_SECRET_ACCESS_KEY", "") + t.Setenv("AWS_SESSION_TOKEN", "") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + t.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "") + t.Setenv("AWS_CONTAINER_CREDENTIALS_FULL_URI", "") + t.Setenv("AWS_ROLE_ARN", "") + t.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "") +} + +// TestRealPurchasePastProviderRegistration is the regression guard for the +// bug this file's blank imports fix: cudly-mcp never imported +// providers/aws|azure|gcp, so their init()-registered factories were never +// added to provider.CreateProvider's registry, and every real (non-dry-run) +// purchase failed at ResolveClient with "provider aws is not registered" +// before ever reaching AWS. +// +// This test MUST live in package main under cmd/cudly-mcp/ -- go test +// ./mcp/... does not catch this bug even with the blank imports reverted, +// because a test binary for the mcp or mcp/tools package never pulls in +// cmd/cudly-mcp's imports. Only a test in this package has the blank +// imports in its own dependency graph, so only here does reverting them +// actually flip provider.CreateProvider("aws") back to unregistered. +// +// The test asserts the purchase attempt gets PAST registration and fails for +// a completely different, credentials-shaped reason ("AWS is not +// configured", from providers/aws/provider.go's GetServiceClient) instead of +// "not registered". It never reaches AWS: isolateFromAmbientAWS makes +// config.LoadDefaultConfig fail to resolve the (deliberately nonexistent) +// named profile before any credential lookup or network call happens. +func TestRealPurchasePastProviderRegistration(t *testing.T) { + isolateFromAmbientAWS(t) + + server, err := cudlymcp.NewServer("test-regression") + require.NoError(t, err) + + clientTransport, serverTransport := gosdk.NewInMemoryTransports() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + go func() { + _ = server.Run(ctx, serverTransport) + }() + + client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &gosdk.CallToolParams{ + Name: "cudly_aws_ec2_ri_purchase", + Arguments: map[string]any{ + "region": "us-east-1", + "instance_type": "m5.large", + "count": 1, + "term_years": 1, + "payment_option": "no-upfront", + "dry_run": false, + "confirm": true, + }, + }) + require.NoError(t, err, "CallTool itself must not return a transport-level error") + require.True(t, result.IsError, "a failed real purchase must surface as a tool error, not a transport error") + + text := result.Content[0].(*gosdk.TextContent).Text + assert.NotContains(t, strings.ToLower(text), "not registered", + "provider must be registered for the cudly-mcp binary: got %q", text) + // providers/aws/provider.go's GetServiceClient (via AWSProvider.IsConfigured) + // returns exactly this string when config.LoadDefaultConfig cannot resolve + // the requested profile -- observed and confirmed stable in this test run. + assert.Contains(t, text, "AWS is not configured", + "expected a credentials/config-shaped failure once past registration: got %q", text) +} From eb7efcc61a74ab8280c2687055e8c562a50e8fdf Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:22:26 +0200 Subject: [PATCH 28/83] fix(mcp): prevent idempotency-key collisions across time-separated purchases idempotencyKeyFor derived the token purely from purchase parameters (provider/region/service/resource/count/term/payment/details), so two distinct, intentional purchases with identical parameters (e.g. "buy 3 m5.large RIs now" and "buy 3 more next week") hashed to the same token. findRIByIdempotencyToken then treated the second purchase as a retry of the first and silently skipped it. Fold a discriminator into the key: an explicit caller-supplied idempotency_nonce when provided, otherwise an automatic hourly time bucket. A rapid same-bucket retry after a network timeout still dedupes as before; purchases separated by more than a bucket no longer collide. A caller wanting strict long-lived dedup can pass the same nonce on both calls. Add idempotency_nonce as an optional argument on every purchase tool and update the README's safety-model and troubleshooting sections. --- mcp/README.md | 4 +- mcp/tools/aws_ec2_ri.go | 24 ++++--- mcp/tools/aws_elasticache_ri.go | 20 +++--- mcp/tools/aws_rds_ri.go | 22 +++--- mcp/tools/aws_savingsplans.go | 2 + mcp/tools/aws_simple_ri.go | 18 ++--- mcp/tools/azure_compute_ri.go | 2 + mcp/tools/gcp_computeengine_cud.go | 18 ++--- mcp/tools/purchase.go | 51 ++++++++++++-- mcp/tools/purchase_test.go | 108 +++++++++++++++++++++++++++-- 10 files changed, 211 insertions(+), 58 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 754e33055..6eaebd5b2 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -97,7 +97,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. - A real purchase requires **both** `dry_run=false` **and** `confirm=true`. `dry_run=false` with `confirm=false` (or vice versa) is refused with a structured error, not silently downgraded to a preview or silently ignored. - 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, so retrying an identical tool call after a network error dedupes at the provider instead of buying twice. +- 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 plus a discriminator, so retrying an identical tool call after a network error dedupes at the provider instead of buying twice. By default the discriminator is the current hour (an automatic time bucket), not just the parameters -- so a rapid retry within the same hour still dedupes correctly, but two calls with otherwise-identical parameters made further apart (e.g. "buy 3 RIs now" and "buy 3 more next week") are treated as genuinely separate purchases and get distinct tokens, rather than the second one silently colliding with and being skipped as a "retry" of the first. Pass the same `idempotency_nonce` value on both calls to force strict dedup regardless of how much time elapses between them. - Provider/SDK failures surface their full error text back to the caller; nothing is swallowed. ## Caveats and known gaps @@ -115,5 +115,5 @@ These are pre-existing behaviours in the underlying purchase clients, not someth - **"provider ... is not configured" / credential errors**: confirm the relevant environment variable(s) from [Configure credentials](#configure-credentials) are set in the shell (or the client's `env` block) that launches `cudly-mcp`, or pass the matching per-call override (`aws_profile` / `azure_subscription_id` / `gcp_project_id`). - **Azure/GCP purchase calls appear to hang**: Azure Reservations and GCP Compute Commitments both provision asynchronously after the purchase call returns; a `success=true` response means the purchase request was accepted, not necessarily that the resource is already active in the portal/console. Re-run `cudly_search_recommendations` or check the provider console if you need to confirm activation state. -- **Rate limits / throttling from the cloud provider**: retry the same tool call with the same parameters -- the idempotency token guarantees a retry cannot double-purchase. +- **Rate limits / throttling from the cloud provider**: retry the same tool call with the same parameters -- the idempotency token guarantees a retry cannot double-purchase, as long as the retry happens within the same automatic time bucket (one hour by default; see [Safety model](#safety-model)). For a deliberate retry across a longer gap, pass the same `idempotency_nonce` value on both calls to force the same idempotency key regardless of elapsed time. - **"invalid ... must be one of ..." errors**: every enum-typed parameter (term, payment option, engine, az_config, sp_type, scope, tenancy, platform) is validated against an explicit allow-list; call `cudly_list_commitment_actions` or re-check this README's per-tool schema for the exact accepted values. diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go index 6ecdcfb29..c843018dd 100644 --- a/mcp/tools/aws_ec2_ri.go +++ b/mcp/tools/aws_ec2_ri.go @@ -26,17 +26,18 @@ const awsEC2RIPurchaseDescription = "Purchase AWS EC2 Reserved Instances. THIS S // (on-demand Linux, shared tenancy, region-scoped) but are always visible in // the schema and can be overridden per call. type ec2RIPurchaseArgs struct { - Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` - InstanceType string `json:"instance_type" jsonschema:"EC2 instance type, e.g. m5.large"` - Count int `json:"count" jsonschema:"number of instances to reserve, must be > 0"` - TermYears int `json:"term_years" jsonschema:"commitment length in years"` - PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` - Platform string `json:"platform,omitempty" jsonschema:"RI product description (operating system); defaults to Linux/UNIX"` - Tenancy string `json:"tenancy,omitempty" jsonschema:"instance tenancy; defaults to default (shared)"` - 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"` - DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` - Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` + InstanceType string `json:"instance_type" jsonschema:"EC2 instance type, e.g. m5.large"` + Count int `json:"count" jsonschema:"number of instances to reserve, must be > 0"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` + Platform string `json:"platform,omitempty" jsonschema:"RI product description (operating system); defaults to Linux/UNIX"` + Tenancy string `json:"tenancy,omitempty" jsonschema:"instance tenancy; defaults to default (shared)"` + 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"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` } type awsEC2RIPurchaseTool struct { @@ -95,6 +96,7 @@ func (t *awsEC2RIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques DryRun: dryRun, Confirm: confirm, ResolveClient: t.resolveClient(args), + Nonce: args.IdempotencyNonce, }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/aws_elasticache_ri.go b/mcp/tools/aws_elasticache_ri.go index 8aabcd4ba..9b6362402 100644 --- a/mcp/tools/aws_elasticache_ri.go +++ b/mcp/tools/aws_elasticache_ri.go @@ -21,15 +21,16 @@ const awsElastiCacheRIPurchaseDescription = "Purchase AWS ElastiCache Reserved C // which providers/aws/services/elasticache/client.go:273-283 requires for // the offering lookup. type elasticacheRIPurchaseArgs struct { - Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` - NodeType string `json:"node_type" jsonschema:"ElastiCache cache node type, e.g. cache.r6g.large"` - Count int `json:"count" jsonschema:"number of cache nodes to reserve, must be > 0"` - TermYears int `json:"term_years" jsonschema:"commitment length in years"` - PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` - Engine string `json:"engine" jsonschema:"cache engine"` - AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` - DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` - Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` + NodeType string `json:"node_type" jsonschema:"ElastiCache cache node type, e.g. cache.r6g.large"` + Count int `json:"count" jsonschema:"number of cache nodes to reserve, must be > 0"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` + Engine string `json:"engine" jsonschema:"cache engine"` + AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` } type awsElastiCacheRIPurchaseTool struct { @@ -87,6 +88,7 @@ func (t *awsElastiCacheRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTo DryRun: dryRun, Confirm: confirm, ResolveClient: t.resolveClient(args), + Nonce: args.IdempotencyNonce, }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/aws_rds_ri.go b/mcp/tools/aws_rds_ri.go index af68f6079..bdfc0054f 100644 --- a/mcp/tools/aws_rds_ri.go +++ b/mcp/tools/aws_rds_ri.go @@ -23,16 +23,17 @@ const awsRDSRIPurchaseDescription = "Purchase AWS RDS Reserved Instances. THIS S // prices and do not cover each other's demand), so unlike EC2's // platform/tenancy/scope it is a required field here, not a defaulted one. type rdsRIPurchaseArgs struct { - Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` - InstanceClass string `json:"instance_class" jsonschema:"RDS DB instance class, e.g. db.r6g.large"` - Count int `json:"count" jsonschema:"number of instances to reserve, must be > 0"` - TermYears int `json:"term_years" jsonschema:"commitment length in years"` - PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` - Engine string `json:"engine" jsonschema:"RDS database engine, e.g. mysql, postgres, mariadb, oracle-se2, sqlserver-ee"` - 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"` - DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` - Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` + InstanceClass string `json:"instance_class" jsonschema:"RDS DB instance class, e.g. db.r6g.large"` + Count int `json:"count" jsonschema:"number of instances to reserve, must be > 0"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + PaymentOption string `json:"payment_option" jsonschema:"payment schedule"` + Engine string `json:"engine" jsonschema:"RDS database engine, e.g. mysql, postgres, mariadb, oracle-se2, sqlserver-ee"` + 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"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` } type awsRDSRIPurchaseTool struct { @@ -90,6 +91,7 @@ func (t *awsRDSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques DryRun: dryRun, Confirm: confirm, ResolveClient: t.resolveClient(args), + Nonce: args.IdempotencyNonce, }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index ee1171310..1d92e9000 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -43,6 +43,7 @@ type savingsPlansPurchaseArgs struct { AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` } type awsSavingsPlansPurchaseTool struct { @@ -102,6 +103,7 @@ func (t *awsSavingsPlansPurchaseTool) handle(ctx context.Context, _ *mcp.CallToo DryRun: dryRun, Confirm: confirm, ResolveClient: t.resolveClient(args, region, rec.Service), + Nonce: args.IdempotencyNonce, }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index cae1619a8..33d6ce364 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -29,14 +29,15 @@ type simpleAWSRIPurchaseSpec struct { // simpleAWSRIPurchaseArgs is the input schema shared by every // simpleAWSRIPurchaseTool instance. type simpleAWSRIPurchaseArgs struct { - Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` - ResourceType string `json:"resource_type" jsonschema:"resource/node type to reserve"` - Count int `json:"count" jsonschema:"number of nodes/instances to reserve, must be > 0"` - TermYears int `json:"term_years" jsonschema:"commitment length in years"` - 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"` - DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` - Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + Region string `json:"region" jsonschema:"AWS region, e.g. us-east-1"` + ResourceType string `json:"resource_type" jsonschema:"resource/node type to reserve"` + Count int `json:"count" jsonschema:"number of nodes/instances to reserve, must be > 0"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + 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"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` } type simpleAWSRIPurchaseTool struct { @@ -144,6 +145,7 @@ func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReq DryRun: dryRun, Confirm: confirm, ResolveClient: t.resolveClient(args), + Nonce: args.IdempotencyNonce, }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index b6ea9f8fa..a7fec1908 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -42,6 +42,7 @@ type azureComputeRIPurchaseArgs struct { AzureSubscriptionID string `json:"azure_subscription_id,omitempty" jsonschema:"Azure subscription ID override; default uses AZURE_SUBSCRIPTION_ID"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` } type azureComputeRIPurchaseTool struct { @@ -98,6 +99,7 @@ func (t *azureComputeRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTool DryRun: dryRun, Confirm: confirm, ResolveClient: t.resolveClient(args), + Nonce: args.IdempotencyNonce, }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index 831bd40fc..e1bf650d3 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -25,14 +25,15 @@ const gcpComputeEngineCUDPurchaseDescription = "Purchase a GCP Compute Engine Co // pointer, unlike every AWS Details assertion) and hard-errors when // MemoryGB is absent or <= 0 rather than guessing a vCPU:memory ratio. type gcpComputeEngineCUDPurchaseArgs struct { - Region string `json:"region" jsonschema:"GCP region, e.g. us-central1"` - MachineType string `json:"machine_type" jsonschema:"GCP machine type family for the commitment, e.g. n2-standard-4"` - 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"` - TermYears int `json:"term_years" jsonschema:"commitment length in years"` - GCPProjectID string `json:"gcp_project_id,omitempty" jsonschema:"GCP project ID override; default uses ambient project"` - DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` - Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + Region string `json:"region" jsonschema:"GCP region, e.g. us-central1"` + MachineType string `json:"machine_type" jsonschema:"GCP machine type family for the commitment, e.g. n2-standard-4"` + 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"` + TermYears int `json:"term_years" jsonschema:"commitment length in years"` + GCPProjectID string `json:"gcp_project_id,omitempty" jsonschema:"GCP project ID override; default uses ambient project"` + DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` + Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` + IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` } type gcpComputeEngineCUDPurchaseTool struct { @@ -88,6 +89,7 @@ func (t *gcpComputeEngineCUDPurchaseTool) handle(ctx context.Context, _ *mcp.Cal DryRun: dryRun, Confirm: confirm, ResolveClient: t.resolveClient(args), + Nonce: args.IdempotencyNonce, }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index 152fb4133..fb6fce792 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -55,6 +55,15 @@ type PurchaseRequest struct { DryRun bool Confirm bool ResolveClient ResolveClientFunc + + // Nonce is optional; when non-empty it is used verbatim as the + // idempotency discriminator instead of the automatic time bucket, + // letting a caller force two calls to dedupe as the same purchase + // regardless of elapsed time. When empty (the default), the + // discriminator is derived automatically from the current time bucket + // so identical-looking-but-actually-separate purchases don't silently + // collide. See idempotencyKeyFor. + Nonce string } // PurchaseResponse is the structured result returned to the MCP caller for @@ -99,6 +108,22 @@ func nonZeroCostPtr(v float64) *float64 { return &v } +// idempotencyBucket is the width of the automatic time-based discriminator +// folded into the idempotency key when the caller does not supply an +// explicit idempotency_nonce (see idempotencyKeyFor). Wide enough that a +// caller's own rapid retry of a recent call (e.g. after a network timeout -- +// this guard's original purpose) still lands in the same bucket and dedupes +// as before; narrow enough that two genuinely separate purchases made hours +// or days apart (e.g. "buy 3 RIs now, buy 3 more next week" -- the +// adversarial review finding this constant fixes) never collide by +// accident. +const idempotencyBucket = time.Hour + +// idempotencyClock is a seam so tests can freeze "now" and assert exact +// bucket-boundary behavior deterministically instead of depending on +// wall-clock timing. +var idempotencyClock = time.Now + // idempotencyKeyFor derives a stable per-request key from every field that // identifies what is being bought: provider, region, service, resource type, // count, term, payment option, plus every service-specific dimension held in @@ -120,10 +145,28 @@ func nonZeroCostPtr(v float64) *float64 { // rec.Account is deliberately excluded: no *FromArgs constructor in this // package populates it today, so folding it in would add an always-empty, // misleading key component rather than real discrimination. -func idempotencyKeyFor(region string, rec common.Recommendation) string { - return fmt.Sprintf("mcp:%s:%s:%s:%s:%d:%s:%s:%s", +// +// A second issue found in adversarial review: every field above identifies +// WHAT is being bought, not WHEN -- so two genuinely distinct purchases with +// identical parameters (a $5/hr Compute Savings Plan followed a week later +// by a genuinely separate $5/hr purchase) previously collided on the same +// token, and providers/aws/services/ec2/client.go's findRIByIdempotencyToken +// silently treated the second, real purchase as a retry of the first and +// skipped it. nonce and idempotencyBucket fix this: an explicit +// caller-supplied nonce is used verbatim as the discriminator when present +// (letting a caller force strict, long-lived dedup across an arbitrary gap); +// otherwise the discriminator falls back to the current idempotencyBucket- +// wide time bucket, so a rapid retry within the same bucket still dedupes as +// before, but two calls separated by more than a bucket width derive +// different keys and both purchases go through. +func idempotencyKeyFor(region string, rec common.Recommendation, nonce string) string { + discriminator := nonce + if discriminator == "" { + discriminator = idempotencyClock().UTC().Truncate(idempotencyBucket).Format(time.RFC3339) + } + return fmt.Sprintf("mcp:%s:%s:%s:%s:%d:%s:%s:%s:%s", rec.Provider, region, rec.Service, rec.ResourceType, rec.Count, rec.Term, rec.PaymentOption, - detailsKeyComponent(rec.Details)) + detailsKeyComponent(rec.Details), discriminator) } // detailsKeyComponent returns a canonical, deterministic encoding of every @@ -213,7 +256,7 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons return nil, fmt.Errorf("resolve %s service client: %w", rec.Provider, err) } - token := common.DeriveIdempotencyToken(idempotencyKeyFor(req.Region, rec), 0) + token := common.DeriveIdempotencyToken(idempotencyKeyFor(req.Region, rec, req.Nonce), 0) opts := common.PurchaseOptions{ Source: common.PurchaseSourceMCP, IdempotencyToken: token, diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index e9efecb99..b62daf7f4 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,6 +14,18 @@ import ( "github.com/LeanerCloud/CUDly/pkg/provider" ) +// freezeIdempotencyClock overrides idempotencyClock for the duration of a +// test so idempotencyKeyFor's automatic time-bucket discriminator is +// deterministic rather than depending on wall-clock timing. Returns a +// restore func; callers should defer it (or call it explicitly if freezing +// multiple distinct instants within one test). +func freezeIdempotencyClock(t *testing.T, at time.Time) func() { + t.Helper() + orig := idempotencyClock + idempotencyClock = func() time.Time { return at } + return func() { idempotencyClock = orig } +} + // fakeServiceClient is a minimal provider.ServiceClient test double. Only // PurchaseCommitment is exercised by these tests; the rest of the interface // is implemented trivially to satisfy the type. @@ -325,7 +338,14 @@ func TestExecutePurchaseResolveClientErrorSurfaced(t *testing.T) { // have silently deduped the second call as a "retry" of the first instead // of buying a second, larger plan. func TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment(t *testing.T) { - t.Parallel() + // Not t.Parallel(): this test freezes the package-level idempotencyClock, + // which several other tests in this file read (via idempotencyKeyFor / + // ExecutePurchase) while running in the parallel batch. Staying in the + // serial phase means the freeze/restore cycle completes before any + // parallel test's body executes, so there is no concurrent access to the + // shared var. + defer freezeIdempotencyClock(t, time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))() + cheapArgs := validSavingsPlansArgs() cheapArgs.HourlyCommitment = 5 expensiveArgs := validSavingsPlansArgs() @@ -336,8 +356,8 @@ func TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment(t *testing.T) { expensiveRec, _, _, _, err := savingsPlanRecommendationFromArgs(expensiveArgs) require.NoError(t, err) - cheapKey := idempotencyKeyFor(region, cheapRec) - expensiveKey := idempotencyKeyFor(region, expensiveRec) + cheapKey := idempotencyKeyFor(region, cheapRec, "") + expensiveKey := idempotencyKeyFor(region, expensiveRec, "") assert.NotEqual(t, cheapKey, expensiveKey, "a $5/hr and a $50/hr Compute Savings Plan must not derive the same idempotency key") } @@ -349,7 +369,9 @@ func TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment(t *testing.T) { // product-affecting dimension carried in rec.Details, and the pre-fix key // derivation ignored Details entirely. func TestIdempotencyKeyDistinguishesEC2Platform(t *testing.T) { - t.Parallel() + // Not t.Parallel(): see TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment. + defer freezeIdempotencyClock(t, time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))() + linuxArgs := validEC2Args() linuxArgs.Platform = "Linux/UNIX" windowsArgs := validEC2Args() @@ -360,8 +382,82 @@ func TestIdempotencyKeyDistinguishesEC2Platform(t *testing.T) { windowsRec, _, _, err := ec2RecommendationFromArgs(windowsArgs) require.NoError(t, err) - linuxKey := idempotencyKeyFor(linuxArgs.Region, linuxRec) - windowsKey := idempotencyKeyFor(windowsArgs.Region, windowsRec) + linuxKey := idempotencyKeyFor(linuxArgs.Region, linuxRec, "") + windowsKey := idempotencyKeyFor(windowsArgs.Region, windowsRec, "") assert.NotEqual(t, linuxKey, windowsKey, "a Linux and a Windows EC2 RI purchase must not derive the same idempotency key") } + +// TestIdempotencyKeyNonceOverridesTimeBucket proves the three scenarios the +// nonce/time-bucket design must satisfy: an explicit nonce always wins over +// elapsed time (strict caller-controlled dedup); with no nonce, two calls +// separated by more than idempotencyBucket derive different keys (genuinely +// separate purchases don't collide); with no nonce, two calls in the same +// instant (the same bucket) derive the same key (a rapid retry still +// dedupes, preserving this guard's original purpose). +func TestIdempotencyKeyNonceOverridesTimeBucket(t *testing.T) { + // Not t.Parallel(): see TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment. + rec := testRecommendation() + region := "us-east-1" + weekApart1 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + weekApart2 := weekApart1.Add(7 * 24 * time.Hour) + + t.Run("same explicit nonce wins over a week apart", func(t *testing.T) { + defer freezeIdempotencyClock(t, weekApart1)() + key1 := idempotencyKeyFor(region, rec, "retry-abc") + freezeIdempotencyClock(t, weekApart2) + key2 := idempotencyKeyFor(region, rec, "retry-abc") + assert.Equal(t, key1, key2, "an explicit shared nonce must dedupe regardless of elapsed time") + }) + + t.Run("no nonce and a week apart derives different keys", func(t *testing.T) { + defer freezeIdempotencyClock(t, weekApart1)() + key1 := idempotencyKeyFor(region, rec, "") + freezeIdempotencyClock(t, weekApart2) + key2 := idempotencyKeyFor(region, rec, "") + assert.NotEqual(t, key1, key2, + "two genuinely separate purchases a week apart must not collide when no nonce is supplied") + }) + + t.Run("no nonce and the same instant derives the same key", func(t *testing.T) { + defer freezeIdempotencyClock(t, weekApart1)() + key1 := idempotencyKeyFor(region, rec, "") + key2 := idempotencyKeyFor(region, rec, "") + assert.Equal(t, key1, key2, "a rapid retry within the same time bucket must still dedupe") + }) +} + +// TestExecutePurchaseNonceThreadedThroughToToken proves PurchaseRequest.Nonce +// is actually wired end to end into ExecutePurchase's derived token, not +// just exercised at the idempotencyKeyFor level in isolation. +func TestExecutePurchaseNonceThreadedThroughToToken(t *testing.T) { + t.Parallel() + rec := testRecommendation() + + fake1 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + _, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, Nonce: "call-1", + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake1, nil }, + }) + require.NoError(t, err) + + fake2 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + _, err = ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, Nonce: "call-2", + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake2, nil }, + }) + require.NoError(t, err) + + assert.NotEqual(t, fake1.lastOpts.IdempotencyToken, fake2.lastOpts.IdempotencyToken, + "different nonces must derive different idempotency tokens") + + fake3 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + _, err = ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, Nonce: "call-1", + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake3, nil }, + }) + require.NoError(t, err) + + assert.Equal(t, fake1.lastOpts.IdempotencyToken, fake3.lastOpts.IdempotencyToken, + "the same nonce must derive the same idempotency token") +} From 857061c283695b49accaef449b1420860ce1dd97 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:24:34 +0200 Subject: [PATCH 29/83] fix(mcp): require instance_family for EC2Instance Savings Plans sp_type=EC2Instance already required region but not instance_family. Without it, DescribeSavingsPlansOfferings has no instanceFamily filter and can resolve across every family in the region instead of the one Cost Explorer actually recommended, risking a real purchase for the wrong workload. The provider client's lookupEC2OfferingIDStrict fails loud when the resulting offerings span more than one family, but that is defense in depth at the API boundary; require the field at the tool boundary too, mirroring how region is already required for this sp_type. instance_family stays optional and ignored for Compute, SageMaker, and Database plans, which are family-agnostic and account-level. --- mcp/tools/aws_savingsplans.go | 10 ++++++ mcp/tools/aws_savingsplans_test.go | 53 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index 1d92e9000..ecf8f97d6 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -135,6 +135,16 @@ func validateSavingsPlanArgs(args savingsPlansPurchaseArgs) (spType SPType, term if spType == SPTypeEC2Instance && args.Region == "" { return "", 0, "", fmt.Errorf("region is required for sp_type=%s", SPTypeEC2Instance) } + // instance_family is the filter that stops DescribeSavingsPlansOfferings + // from resolving to an arbitrary EC2Instance offering across every family + // in the region. providers/aws/services/savingsplans/client.go's + // lookupEC2OfferingIDStrict does fail loud when the resulting offerings + // span more than one family, but that is defense in depth at the API + // boundary; requiring the family here, at the tool boundary, catches the + // missing value before a real purchase attempt is even made. + if spType == SPTypeEC2Instance && args.InstanceFamily == "" { + return "", 0, "", fmt.Errorf("instance_family is required for sp_type=%s", SPTypeEC2Instance) + } if err := validateDatabaseSPConstraints(spType, term, paymentOption); err != nil { return "", 0, "", err } diff --git a/mcp/tools/aws_savingsplans_test.go b/mcp/tools/aws_savingsplans_test.go index d95254e6b..2d9e7dac2 100644 --- a/mcp/tools/aws_savingsplans_test.go +++ b/mcp/tools/aws_savingsplans_test.go @@ -57,6 +57,59 @@ func TestSavingsPlanRecommendationFromArgsEC2InstanceRequiresRegion(t *testing.T assert.Equal(t, "us-east-1", details.Region) } +// TestSavingsPlanRecommendationFromArgsEC2InstanceRequiresInstanceFamily is +// the regression guard for the CodeRabbit money-path finding: omitting +// instance_family for sp_type=EC2Instance lets DescribeSavingsPlansOfferings +// resolve across every instance family in the region instead of the one +// Cost Explorer actually recommended, risking a real purchase for the wrong +// workload. instance_family must be required exactly like region already is. +func TestSavingsPlanRecommendationFromArgsEC2InstanceRequiresInstanceFamily(t *testing.T) { + t.Parallel() + args := validSavingsPlansArgs() + args.SPType = "EC2Instance" + args.Region = "us-east-1" + + _, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.Error(t, err, "EC2Instance sp_type without instance_family must be rejected") + assert.Contains(t, err.Error(), "instance_family is required") + + args.InstanceFamily = "m5" + rec, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err, "EC2Instance sp_type with instance_family set must succeed") + details, ok := rec.Details.(*common.SavingsPlanDetails) + require.True(t, ok) + assert.Equal(t, "m5", details.InstanceFamily) +} + +// TestSavingsPlanRecommendationFromArgsInstanceFamilyOptionalForOtherTypes +// proves the new instance_family requirement is scoped to sp_type=EC2Instance +// only: Compute, SageMaker, and Database plans are family-agnostic and +// account-level, so instance_family stays optional (and ignored) for them. +func TestSavingsPlanRecommendationFromArgsInstanceFamilyOptionalForOtherTypes(t *testing.T) { + t.Parallel() + for _, spType := range []string{"Compute", "SageMaker"} { + t.Run(spType, func(t *testing.T) { + args := validSavingsPlansArgs() + args.SPType = spType + args.InstanceFamily = "" + + _, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err, "instance_family must remain optional for sp_type=%s", spType) + }) + } + + t.Run("Database", func(t *testing.T) { + args := validSavingsPlansArgs() + args.SPType = "Database" + args.TermYears = 1 + args.PaymentOption = "no-upfront" + args.InstanceFamily = "" + + _, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err, "instance_family must remain optional for sp_type=Database") + }) +} + func TestSavingsPlanRecommendationFromArgsInvalid(t *testing.T) { t.Parallel() cases := []struct { From d0e46be25a121c330f6abd42a854b024e4330013 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:26:51 +0200 Subject: [PATCH 30/83] fix(mcp): include cudly_list_commitment_actions in its own catalog NewServer captured the descriptors slice before appending the list tool to regs, so the catalog cudly_list_commitment_actions returns at runtime never included its own entry, despite its description claiming to list every tool on the server. Export ListCommitmentActionsDescriptor so its static entry can be appended to the descriptors slice before the tool itself is constructed, and add an end-to-end test that calls the tool through the real NewServer wiring and asserts its own name appears in the result. --- mcp/server.go | 4 +++ mcp/server_test.go | 48 ++++++++++++++++++++++++++++ mcp/tools/list_commitment_actions.go | 12 ++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/mcp/server.go b/mcp/server.go index de4cc2a11..65142099c 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -50,6 +50,10 @@ func NewServer(version string) (*gosdk.Server, error) { for _, r := range regs { descriptors = append(descriptors, r.Descriptor()) } + // Include cudly_list_commitment_actions' own entry so the catalog it + // returns really does list "every tool available on this MCP server" as + // its description promises, itself included. + descriptors = append(descriptors, tools.ListCommitmentActionsDescriptor()) listTool := tools.NewListCommitmentActions(descriptors) regs = append(regs, listTool) diff --git a/mcp/server_test.go b/mcp/server_test.go index c53b79ccd..690be624d 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -107,6 +107,54 @@ func TestEndToEndSearchThenDryRunPurchase(t *testing.T) { assert.Empty(t, resp.Error) } +// TestListCommitmentActionsIncludesItself is the regression guard for the +// CodeRabbit finding that NewServer built the descriptors slice passed to +// cudly_list_commitment_actions before appending the list tool itself to +// regs, so the catalog the tool actually returns at runtime never included +// its own entry despite its description claiming to list "every" tool. +// Unlike TestRegistryNonEmpty (which builds its own descriptors slice by +// hand) this drives the real NewServer wiring and calls the live tool over +// the protocol, so it fails if NewServer regresses even if the manual +// helper above stays correct. +func TestListCommitmentActionsIncludesItself(t *testing.T) { + t.Parallel() + ctx := context.Background() + + server, err := NewServer("test") + require.NoError(t, err) + + clientTransport, serverTransport := gosdk.NewInMemoryTransports() + go func() { + _ = server.Run(ctx, serverTransport) + }() + + client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &gosdk.CallToolParams{ + Name: "cudly_list_commitment_actions", + Arguments: map[string]any{}, + }) + require.NoError(t, err) + require.False(t, result.IsError, "cudly_list_commitment_actions must not itself error") + + structured, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + var catalog struct { + Actions []tools.ActionEntry `json:"actions"` + } + require.NoError(t, json.Unmarshal(structured, &catalog)) + + var names []string + for _, a := range catalog.Actions { + names = append(names, a.Name) + } + assert.Contains(t, names, "cudly_list_commitment_actions", + "the catalog cudly_list_commitment_actions returns must include its own entry, got: %v", names) +} + func TestRealPurchaseToolsDocumentMoneyImpactAndDryRun(t *testing.T) { t.Parallel() for _, r := range registrations() { diff --git a/mcp/tools/list_commitment_actions.go b/mcp/tools/list_commitment_actions.go index c394d78ec..a9743124e 100644 --- a/mcp/tools/list_commitment_actions.go +++ b/mcp/tools/list_commitment_actions.go @@ -46,7 +46,13 @@ func NewListCommitmentActions(descriptors []Descriptor) Registration { return &listCommitmentActionsTool{descriptors: descriptors} } -func (t *listCommitmentActionsTool) Descriptor() Descriptor { +// ListCommitmentActionsDescriptor returns the static Descriptor for +// cudly_list_commitment_actions itself. It is exported so mcp/server.go can +// include this tool in its own catalog: the descriptors slice passed to +// NewListCommitmentActions must be assembled (and thus known) before the +// tool exists, so its own entry can't come from calling Descriptor() on an +// already-constructed instance the way every other tool's entry does. +func ListCommitmentActionsDescriptor() Descriptor { return Descriptor{ Name: listCommitmentActionsName, Description: listCommitmentActionsDescription, @@ -58,6 +64,10 @@ func (t *listCommitmentActionsTool) Descriptor() Descriptor { } } +func (t *listCommitmentActionsTool) Descriptor() Descriptor { + return ListCommitmentActionsDescriptor() +} + func (t *listCommitmentActionsTool) Register(s *mcp.Server) error { schema, err := BuildInputSchema[listCommitmentActionsArgs](nil) if err != nil { From 1a4e87465eed103e4c053d62cd5ffcf7e6d3b9be Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:33:47 +0200 Subject: [PATCH 31/83] fix(mcp): reject whitespace-only required string fields A bare `== ""` check on region/instance_type/vm_size/machine_type/etc lets a whitespace-only value like " " through to provider resolution on a confirmed real purchase, since it is neither empty nor a value the enum validators would catch. Add a requireNonBlank helper that trims before checking, and use it at every plain (non-enum) required-string check across the AWS EC2, RDS, ElastiCache, and simple RI tools, the AWS Savings Plans EC2Instance region/instance_family checks, and the Azure and GCP purchase tools. Enum-typed fields (payment_option, engine, term, etc.) already reject a whitespace-only value via their own allow-list check and are unaffected. Add whitespace-only regression cases to each tool's existing missing-required-field table tests. --- mcp/tools/aws_ec2_ri.go | 8 +++---- mcp/tools/aws_ec2_ri_test.go | 2 ++ mcp/tools/aws_elasticache_ri.go | 8 +++---- mcp/tools/aws_elasticache_ri_test.go | 2 ++ mcp/tools/aws_rds_ri.go | 12 +++++----- mcp/tools/aws_rds_ri_test.go | 3 +++ mcp/tools/aws_savingsplans.go | 5 +++-- mcp/tools/aws_savingsplans_test.go | 30 +++++++++++++++++++++++++ mcp/tools/aws_simple_ri.go | 8 +++---- mcp/tools/aws_simple_ri_test.go | 2 ++ mcp/tools/azure_compute_ri.go | 8 +++---- mcp/tools/azure_compute_ri_test.go | 2 ++ mcp/tools/enums.go | 14 ++++++++++++ mcp/tools/gcp_computeengine_cud.go | 8 +++---- mcp/tools/gcp_computeengine_cud_test.go | 2 ++ 15 files changed, 86 insertions(+), 28 deletions(-) diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go index c843018dd..4b3723245 100644 --- a/mcp/tools/aws_ec2_ri.go +++ b/mcp/tools/aws_ec2_ri.go @@ -161,11 +161,11 @@ func effectiveDryRunConfirm(args ec2RIPurchaseArgs) (dryRun, confirm bool) { // common.Recommendation to purchase, plus the effective dry_run/confirm // booleans. func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if args.Region == "" { - return common.Recommendation{}, false, false, fmt.Errorf("region is required") + if err := requireNonBlank("region", args.Region); err != nil { + return common.Recommendation{}, false, false, err } - if args.InstanceType == "" { - return common.Recommendation{}, false, false, fmt.Errorf("instance_type is required") + if err := requireNonBlank("instance_type", args.InstanceType); err != nil { + return common.Recommendation{}, false, false, err } if args.Count <= 0 { return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) diff --git a/mcp/tools/aws_ec2_ri_test.go b/mcp/tools/aws_ec2_ri_test.go index f3517ddec..a1bdc98a5 100644 --- a/mcp/tools/aws_ec2_ri_test.go +++ b/mcp/tools/aws_ec2_ri_test.go @@ -70,7 +70,9 @@ func TestEC2RecommendationFromArgsMissingRequiredFields(t *testing.T) { errSub string }{ {"missing region", func(a *ec2RIPurchaseArgs) { a.Region = "" }, "region is required"}, + {"whitespace-only region", func(a *ec2RIPurchaseArgs) { a.Region = " " }, "region is required"}, {"missing instance_type", func(a *ec2RIPurchaseArgs) { a.InstanceType = "" }, "instance_type is required"}, + {"whitespace-only instance_type", func(a *ec2RIPurchaseArgs) { a.InstanceType = "\t " }, "instance_type is required"}, {"zero count", func(a *ec2RIPurchaseArgs) { a.Count = 0 }, "count must be"}, {"negative count", func(a *ec2RIPurchaseArgs) { a.Count = -1 }, "count must be"}, {"invalid term", func(a *ec2RIPurchaseArgs) { a.TermYears = 2 }, "invalid term_years"}, diff --git a/mcp/tools/aws_elasticache_ri.go b/mcp/tools/aws_elasticache_ri.go index 9b6362402..84c3a595d 100644 --- a/mcp/tools/aws_elasticache_ri.go +++ b/mcp/tools/aws_elasticache_ri.go @@ -97,11 +97,11 @@ func (t *awsElastiCacheRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTo } func elasticacheRIPurchaseRequiredFields(args elasticacheRIPurchaseArgs) error { - if args.Region == "" { - return fmt.Errorf("region is required") + if err := requireNonBlank("region", args.Region); err != nil { + return err } - if args.NodeType == "" { - return fmt.Errorf("node_type is required") + if err := requireNonBlank("node_type", args.NodeType); err != nil { + return err } if args.Count <= 0 { return fmt.Errorf("count must be > 0, got %d", args.Count) diff --git a/mcp/tools/aws_elasticache_ri_test.go b/mcp/tools/aws_elasticache_ri_test.go index 891a59000..598c3e89a 100644 --- a/mcp/tools/aws_elasticache_ri_test.go +++ b/mcp/tools/aws_elasticache_ri_test.go @@ -43,7 +43,9 @@ func TestElastiCacheRecommendationFromArgsInvalid(t *testing.T) { errSub string }{ {"missing region", func(a *elasticacheRIPurchaseArgs) { a.Region = "" }, "region is required"}, + {"whitespace-only region", func(a *elasticacheRIPurchaseArgs) { a.Region = " " }, "region is required"}, {"missing node_type", func(a *elasticacheRIPurchaseArgs) { a.NodeType = "" }, "node_type is required"}, + {"whitespace-only node_type", func(a *elasticacheRIPurchaseArgs) { a.NodeType = "\t " }, "node_type is required"}, {"zero count", func(a *elasticacheRIPurchaseArgs) { a.Count = 0 }, "count must be"}, {"invalid term", func(a *elasticacheRIPurchaseArgs) { a.TermYears = 5 }, "invalid term_years"}, {"invalid payment option", func(a *elasticacheRIPurchaseArgs) { a.PaymentOption = "bogus" }, "invalid payment_option"}, diff --git a/mcp/tools/aws_rds_ri.go b/mcp/tools/aws_rds_ri.go index bdfc0054f..9b2560da2 100644 --- a/mcp/tools/aws_rds_ri.go +++ b/mcp/tools/aws_rds_ri.go @@ -100,17 +100,17 @@ func (t *awsRDSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques } func rdsRIPurchaseRequiredFields(args rdsRIPurchaseArgs) error { - if args.Region == "" { - return fmt.Errorf("region is required") + if err := requireNonBlank("region", args.Region); err != nil { + return err } - if args.InstanceClass == "" { - return fmt.Errorf("instance_class is required") + if err := requireNonBlank("instance_class", args.InstanceClass); err != nil { + return err } if args.Count <= 0 { return fmt.Errorf("count must be > 0, got %d", args.Count) } - if args.Engine == "" { - return fmt.Errorf("engine is required") + if err := requireNonBlank("engine", args.Engine); err != nil { + return err } return nil } diff --git a/mcp/tools/aws_rds_ri_test.go b/mcp/tools/aws_rds_ri_test.go index 2a4f55766..a399a0c6f 100644 --- a/mcp/tools/aws_rds_ri_test.go +++ b/mcp/tools/aws_rds_ri_test.go @@ -45,8 +45,11 @@ func TestRDSRecommendationFromArgsInvalid(t *testing.T) { errSub string }{ {"missing region", func(a *rdsRIPurchaseArgs) { a.Region = "" }, "region is required"}, + {"whitespace-only region", func(a *rdsRIPurchaseArgs) { a.Region = " " }, "region is required"}, {"missing instance_class", func(a *rdsRIPurchaseArgs) { a.InstanceClass = "" }, "instance_class is required"}, + {"whitespace-only instance_class", func(a *rdsRIPurchaseArgs) { a.InstanceClass = "\t " }, "instance_class is required"}, {"missing engine", func(a *rdsRIPurchaseArgs) { a.Engine = "" }, "engine is required"}, + {"whitespace-only engine", func(a *rdsRIPurchaseArgs) { a.Engine = "\t " }, "engine is required"}, {"zero count", func(a *rdsRIPurchaseArgs) { a.Count = 0 }, "count must be"}, {"invalid term", func(a *rdsRIPurchaseArgs) { a.TermYears = 2 }, "invalid term_years"}, {"invalid payment option", func(a *rdsRIPurchaseArgs) { a.PaymentOption = "bogus" }, "invalid payment_option"}, diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index ecf8f97d6..9aaf8f6aa 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -3,6 +3,7 @@ package tools import ( "context" "fmt" + "strings" spTypes "github.com/aws/aws-sdk-go-v2/service/savingsplans/types" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -132,7 +133,7 @@ func validateSavingsPlanArgs(args savingsPlansPurchaseArgs) (spType SPType, term if err != nil { return "", 0, "", err } - if spType == SPTypeEC2Instance && args.Region == "" { + if spType == SPTypeEC2Instance && strings.TrimSpace(args.Region) == "" { return "", 0, "", fmt.Errorf("region is required for sp_type=%s", SPTypeEC2Instance) } // instance_family is the filter that stops DescribeSavingsPlansOfferings @@ -142,7 +143,7 @@ func validateSavingsPlanArgs(args savingsPlansPurchaseArgs) (spType SPType, term // span more than one family, but that is defense in depth at the API // boundary; requiring the family here, at the tool boundary, catches the // missing value before a real purchase attempt is even made. - if spType == SPTypeEC2Instance && args.InstanceFamily == "" { + if spType == SPTypeEC2Instance && strings.TrimSpace(args.InstanceFamily) == "" { return "", 0, "", fmt.Errorf("instance_family is required for sp_type=%s", SPTypeEC2Instance) } if err := validateDatabaseSPConstraints(spType, term, paymentOption); err != nil { diff --git a/mcp/tools/aws_savingsplans_test.go b/mcp/tools/aws_savingsplans_test.go index 2d9e7dac2..76bf4caaf 100644 --- a/mcp/tools/aws_savingsplans_test.go +++ b/mcp/tools/aws_savingsplans_test.go @@ -57,6 +57,36 @@ func TestSavingsPlanRecommendationFromArgsEC2InstanceRequiresRegion(t *testing.T assert.Equal(t, "us-east-1", details.Region) } +// TestSavingsPlanRecommendationFromArgsEC2InstanceRejectsWhitespaceOnly +// proves region and instance_family are rejected when they contain only +// whitespace, not just when they are the empty string: a bare `== ""` check +// would let " " through to a real EC2Instance Savings Plan purchase. +func TestSavingsPlanRecommendationFromArgsEC2InstanceRejectsWhitespaceOnly(t *testing.T) { + t.Parallel() + + t.Run("whitespace-only region", func(t *testing.T) { + args := validSavingsPlansArgs() + args.SPType = "EC2Instance" + args.InstanceFamily = "m5" + args.Region = " " + + _, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), "region is required") + }) + + t.Run("whitespace-only instance_family", func(t *testing.T) { + args := validSavingsPlansArgs() + args.SPType = "EC2Instance" + args.Region = "us-east-1" + args.InstanceFamily = "\t " + + _, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), "instance_family is required") + }) +} + // TestSavingsPlanRecommendationFromArgsEC2InstanceRequiresInstanceFamily is // the regression guard for the CodeRabbit money-path finding: omitting // instance_family for sp_type=EC2Instance lets DescribeSavingsPlansOfferings diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index 33d6ce364..d85851954 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -154,11 +154,11 @@ func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReq } func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if args.Region == "" { - return common.Recommendation{}, false, false, fmt.Errorf("region is required") + if err := requireNonBlank("region", args.Region); err != nil { + return common.Recommendation{}, false, false, err } - if args.ResourceType == "" { - return common.Recommendation{}, false, false, fmt.Errorf("resource_type is required") + if err := requireNonBlank("resource_type", args.ResourceType); err != nil { + return common.Recommendation{}, false, false, err } if args.Count <= 0 { return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) diff --git a/mcp/tools/aws_simple_ri_test.go b/mcp/tools/aws_simple_ri_test.go index 6b1fa11f0..0f19ce833 100644 --- a/mcp/tools/aws_simple_ri_test.go +++ b/mcp/tools/aws_simple_ri_test.go @@ -99,7 +99,9 @@ func TestSimpleAWSRIPurchaseInvalidArgs(t *testing.T) { errSub string }{ {"missing region", func(a *simpleAWSRIPurchaseArgs) { a.Region = "" }, "region is required"}, + {"whitespace-only region", func(a *simpleAWSRIPurchaseArgs) { a.Region = " " }, "region is required"}, {"missing resource_type", func(a *simpleAWSRIPurchaseArgs) { a.ResourceType = "" }, "resource_type is required"}, + {"whitespace-only resource_type", func(a *simpleAWSRIPurchaseArgs) { a.ResourceType = "\t " }, "resource_type is required"}, {"zero count", func(a *simpleAWSRIPurchaseArgs) { a.Count = 0 }, "count must be"}, {"invalid term", func(a *simpleAWSRIPurchaseArgs) { a.TermYears = 4 }, "invalid term_years"}, {"invalid payment option", func(a *simpleAWSRIPurchaseArgs) { a.PaymentOption = "bogus" }, "invalid payment_option"}, diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index a7fec1908..766e0b5fe 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -108,11 +108,11 @@ func (t *azureComputeRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTool } func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if args.Region == "" { - return common.Recommendation{}, false, false, fmt.Errorf("region is required") + if err := requireNonBlank("region", args.Region); err != nil { + return common.Recommendation{}, false, false, err } - if args.VMSize == "" { - return common.Recommendation{}, false, false, fmt.Errorf("vm_size is required") + if err := requireNonBlank("vm_size", args.VMSize); err != nil { + return common.Recommendation{}, false, false, err } if args.Count <= 0 { return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) diff --git a/mcp/tools/azure_compute_ri_test.go b/mcp/tools/azure_compute_ri_test.go index 4ff5f8b68..2a84c41b6 100644 --- a/mcp/tools/azure_compute_ri_test.go +++ b/mcp/tools/azure_compute_ri_test.go @@ -49,7 +49,9 @@ func TestAzureComputeRecommendationFromArgsInvalid(t *testing.T) { errSub string }{ {"missing region", func(a *azureComputeRIPurchaseArgs) { a.Region = "" }, "region is required"}, + {"whitespace-only region", func(a *azureComputeRIPurchaseArgs) { a.Region = " " }, "region is required"}, {"missing vm_size", func(a *azureComputeRIPurchaseArgs) { a.VMSize = "" }, "vm_size is required"}, + {"whitespace-only vm_size", func(a *azureComputeRIPurchaseArgs) { a.VMSize = "\t\n " }, "vm_size is required"}, {"zero count", func(a *azureComputeRIPurchaseArgs) { a.Count = 0 }, "count must be"}, {"invalid term", func(a *azureComputeRIPurchaseArgs) { a.TermYears = 2 }, "invalid term_years"}, {"invalid payment option", func(a *azureComputeRIPurchaseArgs) { a.PaymentOption = "bogus" }, "invalid payment_option"}, diff --git a/mcp/tools/enums.go b/mcp/tools/enums.go index a1e6f9b06..6db542602 100644 --- a/mcp/tools/enums.go +++ b/mcp/tools/enums.go @@ -5,10 +5,24 @@ package tools import ( "fmt" + "strings" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" ) +// requireNonBlank returns an explicit " is required" error when val is +// empty or contains only whitespace. A whitespace-only value (e.g. " ") +// passes a bare `== ""` check but carries no real region/instance-type/etc +// information, and on a real-purchase path could still reach provider +// resolution instead of being rejected at the MCP tool boundary like an +// actually-empty value already is. +func requireNonBlank(field, val string) error { + if strings.TrimSpace(val) == "" { + return fmt.Errorf("%s is required", field) + } + return nil +} + // PaymentOption is the AWS/Azure/GCP-agnostic reserved-capacity payment // schedule. It is validated at the MCP tool boundary before being copied // onto common.Recommendation.PaymentOption (which stays a bare string there diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index e1bf650d3..a2a2f44c4 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -98,11 +98,11 @@ func (t *gcpComputeEngineCUDPurchaseTool) handle(ctx context.Context, _ *mcp.Cal } func gcpCUDPurchaseRequiredFields(args gcpComputeEngineCUDPurchaseArgs) error { - if args.Region == "" { - return fmt.Errorf("region is required") + if err := requireNonBlank("region", args.Region); err != nil { + return err } - if args.MachineType == "" { - return fmt.Errorf("machine_type is required") + if err := requireNonBlank("machine_type", args.MachineType); err != nil { + return err } if args.VCPUCount <= 0 { return fmt.Errorf("vcpu_count must be > 0, got %d", args.VCPUCount) diff --git a/mcp/tools/gcp_computeengine_cud_test.go b/mcp/tools/gcp_computeengine_cud_test.go index fd4e7fda2..18ebb5d69 100644 --- a/mcp/tools/gcp_computeengine_cud_test.go +++ b/mcp/tools/gcp_computeengine_cud_test.go @@ -51,7 +51,9 @@ func TestGCPComputeEngineRecommendationFromArgsInvalid(t *testing.T) { errSub string }{ {"missing region", func(a *gcpComputeEngineCUDPurchaseArgs) { a.Region = "" }, "region is required"}, + {"whitespace-only region", func(a *gcpComputeEngineCUDPurchaseArgs) { a.Region = " " }, "region is required"}, {"missing machine_type", func(a *gcpComputeEngineCUDPurchaseArgs) { a.MachineType = "" }, "machine_type is required"}, + {"whitespace-only machine_type", func(a *gcpComputeEngineCUDPurchaseArgs) { a.MachineType = "\t " }, "machine_type is required"}, {"zero vcpu_count", func(a *gcpComputeEngineCUDPurchaseArgs) { a.VCPUCount = 0 }, "vcpu_count must be"}, {"zero memory_gb", func(a *gcpComputeEngineCUDPurchaseArgs) { a.MemoryGB = 0 }, "memory_gb must be"}, {"negative memory_gb", func(a *gcpComputeEngineCUDPurchaseArgs) { a.MemoryGB = -1 }, "memory_gb must be"}, From 1dfa2f76aff6519606db3f764aa975af24cac191 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:36:27 +0200 Subject: [PATCH 32/83] fix(mcp): populate TermYears on preview and real purchase responses PurchaseResponse.TermYears was declared in the JSON contract but never set in either branch of ExecutePurchase, so it was always zero and omitted (omitempty) even though the term is known from the request: every *FromArgs constructor in this package writes it into Recommendation.Term in the "yr" format via TermYears.RecommendationTerm(). Add termYearsFromRecommendationTerm to parse that format back into an int and populate TermYears in both the preview and real-purchase response construction. --- mcp/tools/purchase.go | 19 ++++++++++++++++++ mcp/tools/purchase_test.go | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index fb6fce792..bb92f6d1b 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -3,6 +3,8 @@ package tools import ( "context" "fmt" + "strconv" + "strings" "time" "github.com/LeanerCloud/CUDly/pkg/common" @@ -95,6 +97,21 @@ type PurchaseResponse struct { Error string `json:"error,omitempty"` } +// termYearsFromRecommendationTerm extracts the integer commitment length in +// years from a Recommendation.Term string in the "yr" format every +// *FromArgs constructor in this package writes via +// TermYears.RecommendationTerm() (enums.go). Returns 0 when term does not +// match that format (e.g. an empty Term), so PurchaseResponse.TermYears is +// simply omitted (it has `omitempty`) rather than reporting a fabricated +// value. +func termYearsFromRecommendationTerm(term string) int { + years, err := strconv.Atoi(strings.TrimSuffix(term, "yr")) + if err != nil { + return 0 + } + return years +} + // nonZeroCostPtr returns a pointer to v, or nil when v is exactly zero. Cost // and savings fields on common.Recommendation and common.PurchaseResult are // plain (unpointered) float64s that upstream code sometimes never populates @@ -245,6 +262,7 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons OnDemandCost: nonZeroCostPtr(rec.OnDemandCost), EstimatedSavings: nonZeroCostPtr(rec.EstimatedSavings), SavingsPercentage: nonZeroCostPtr(rec.SavingsPercentage), + TermYears: termYearsFromRecommendationTerm(rec.Term), }, nil } @@ -278,6 +296,7 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons EstimatedSavings: nonZeroCostPtr(rec.EstimatedSavings), SavingsPercentage: nonZeroCostPtr(rec.SavingsPercentage), EffectiveDate: result.Timestamp.Format(time.RFC3339), + TermYears: termYearsFromRecommendationTerm(rec.Term), } if result.Error != nil { resp.Error = result.Error.Error() diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index b62daf7f4..dad1a6fa2 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -190,6 +190,46 @@ func TestExecutePurchasePreviewOmitsUnknownCostFields(t *testing.T) { assert.NotContains(t, body, `"savings_percentage"`) } +// TestExecutePurchasePreviewPopulatesTermYears is the regression guard for +// the CodeRabbit finding that PurchaseResponse.TermYears was declared in the +// JSON contract but never set in either ExecutePurchase branch, so it was +// always zero/omitted even though the term is known from the recommendation. +// testRecommendation() carries Term: "3yr", the same "yr" format every +// *FromArgs constructor in this package writes. +func TestExecutePurchasePreviewPopulatesTermYears(t *testing.T) { + t.Parallel() + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", + Recommendation: testRecommendation(), + DryRun: true, + Confirm: false, + }) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, 3, resp.TermYears, "a preview response must carry the term the caller specified") +} + +// TestExecutePurchaseRealPurchasePopulatesTermYears is the real-purchase +// counterpart of TestExecutePurchasePreviewPopulatesTermYears: the term must +// be populated on the modeExecute branch too, not only the preview branch. +func TestExecutePurchaseRealPurchasePopulatesTermYears(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{ + purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "ri-term-test"}, + } + + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", + Recommendation: testRecommendation(), + DryRun: false, + Confirm: true, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, 3, resp.TermYears, "a real-purchase response must carry the term the caller specified") +} + // TestExecutePurchaseUnconfirmedRealPurchaseRefused proves confirm=false // refuses a real purchase (dry_run=false) with a structured error rather // than a silent no-op, and that ResolveClient is never invoked either. From f12fc1179cdafe7039c4b903000ed30775cd8eef Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:37:45 +0200 Subject: [PATCH 33/83] docs(mcp): clarify GOBIN vs GOPATH/bin binary path guidance The register-with-client instructions showed both `$(go env GOBIN)/cudly-mcp` and `$(go env GOPATH)/bin/cudly-mcp` as bare alternatives without saying which applies when. `$(go env GOBIN)` expands to an empty string when GOBIN is unset, so presenting it as a standalone path invites a reader to use an invalid `/cudly-mcp` location. Tie each path explicitly to its GOBIN state, matching the Install section above it. --- mcp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/README.md b/mcp/README.md index 6eaebd5b2..efc44cf71 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -64,7 +64,7 @@ The server speaks MCP over stdio and logs diagnostics to stderr; it does not pri ## Register with an MCP client -Add an entry to your client's MCP server config. For Claude Code, this is `~/.claude/mcp.json`. Use the absolute path `go install` reported (`$(go env GOBIN)/cudly-mcp` or `$(go env GOPATH)/bin/cudly-mcp`) if the client does not inherit your shell's `PATH`: +Add an entry to your client's MCP server config. For Claude Code, this is `~/.claude/mcp.json`. If the client does not inherit your shell's `PATH`, use the absolute path `go install` reported: `$(go env GOBIN)/cudly-mcp` if `GOBIN` is set, otherwise `$(go env GOPATH)/bin/cudly-mcp` (`$(go env GOBIN)` expands to an empty string when `GOBIN` is unset, so that path alone is not a valid binary location): ```json { From e65c102ffa92c013c7839c314c56f53ffb5d5ac3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:40:05 +0200 Subject: [PATCH 34/83] style(mcp): fix golangci-lint v2.10.1 findings Rename the shadowed err in the requireNonBlank checks added to the EC2, simple-RI, and Azure tools to fieldErr (govet shadow flagged the inner err colliding with the named return), and preallocate the names slice in the new list-commitment-actions catalog test (prealloc). --- mcp/server_test.go | 2 +- mcp/tools/aws_ec2_ri.go | 8 ++++---- mcp/tools/aws_simple_ri.go | 8 ++++---- mcp/tools/azure_compute_ri.go | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/mcp/server_test.go b/mcp/server_test.go index 690be624d..58d466270 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -147,7 +147,7 @@ func TestListCommitmentActionsIncludesItself(t *testing.T) { } require.NoError(t, json.Unmarshal(structured, &catalog)) - var names []string + names := make([]string, 0, len(catalog.Actions)) for _, a := range catalog.Actions { names = append(names, a.Name) } diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go index 4b3723245..99b78f5eb 100644 --- a/mcp/tools/aws_ec2_ri.go +++ b/mcp/tools/aws_ec2_ri.go @@ -161,11 +161,11 @@ func effectiveDryRunConfirm(args ec2RIPurchaseArgs) (dryRun, confirm bool) { // common.Recommendation to purchase, plus the effective dry_run/confirm // booleans. func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if err := requireNonBlank("region", args.Region); err != nil { - return common.Recommendation{}, false, false, err + if fieldErr := requireNonBlank("region", args.Region); fieldErr != nil { + return common.Recommendation{}, false, false, fieldErr } - if err := requireNonBlank("instance_type", args.InstanceType); err != nil { - return common.Recommendation{}, false, false, err + if fieldErr := requireNonBlank("instance_type", args.InstanceType); fieldErr != nil { + return common.Recommendation{}, false, false, fieldErr } if args.Count <= 0 { return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index d85851954..25bbb9467 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -154,11 +154,11 @@ func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReq } func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if err := requireNonBlank("region", args.Region); err != nil { - return common.Recommendation{}, false, false, err + if fieldErr := requireNonBlank("region", args.Region); fieldErr != nil { + return common.Recommendation{}, false, false, fieldErr } - if err := requireNonBlank("resource_type", args.ResourceType); err != nil { - return common.Recommendation{}, false, false, err + if fieldErr := requireNonBlank("resource_type", args.ResourceType); fieldErr != nil { + return common.Recommendation{}, false, false, fieldErr } if args.Count <= 0 { return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index 766e0b5fe..6cdb4ae20 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -108,11 +108,11 @@ func (t *azureComputeRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTool } func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if err := requireNonBlank("region", args.Region); err != nil { - return common.Recommendation{}, false, false, err + if fieldErr := requireNonBlank("region", args.Region); fieldErr != nil { + return common.Recommendation{}, false, false, fieldErr } - if err := requireNonBlank("vm_size", args.VMSize); err != nil { - return common.Recommendation{}, false, false, err + if fieldErr := requireNonBlank("vm_size", args.VMSize); fieldErr != nil { + return common.Recommendation{}, false, false, fieldErr } if args.Count <= 0 { return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) From d75659192beb4852227aaae18207429918c7d277 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:55:18 +0200 Subject: [PATCH 35/83] fix(mcp): make purchase idempotency key fail-safe (drop auto time-bucket) Commit 2390f07c0 folded an automatic hourly time bucket into the MCP purchase idempotency key whenever the caller omitted an explicit nonce, to stop two genuinely separate purchases with identical parameters from colliding on the same token. That inverted the safety direction of a money path: a retry that happened to straddle an hour boundary (e.g. issued at 12:59:58, retried four seconds later at 13:00:02) derived a different key, so the provider could treat the retry as a brand new purchase and double-buy instead of deduping it. idempotencyKeyFor no longer reads any clock. With no nonce (the default), identical purchase dimensions always derive the same key regardless of elapsed time, so a retry always dedupes; the worst case is a skipped intentional repeat, never a double purchase. A caller who genuinely wants a second, otherwise-identical purchase authorizes it explicitly by passing a fresh idempotency_nonce, which every purchase tool already exposed; the same nonce on a retry of that call still dedupes. Removed the now-unused idempotencyBucket constant and idempotencyClock seam, updated the idempotencyKeyFor and PurchaseRequest.Nonce doc comments and the README's safety-model/troubleshooting sections to describe the fail-safe model, and replaced the obsolete bucket-boundary tests with a regression test proving identical no-nonce dimensions always derive the same key plus a test proving a nonce authorizes a distinct, still-dedupable purchase. --- mcp/README.md | 4 +- mcp/tools/aws_ec2_ri.go | 2 +- mcp/tools/aws_elasticache_ri.go | 2 +- mcp/tools/aws_rds_ri.go | 2 +- mcp/tools/aws_savingsplans.go | 2 +- mcp/tools/aws_simple_ri.go | 2 +- mcp/tools/azure_compute_ri.go | 2 +- mcp/tools/gcp_computeengine_cud.go | 2 +- mcp/tools/purchase.go | 65 ++++++++------------ mcp/tools/purchase_test.go | 96 +++++++++++++----------------- 10 files changed, 74 insertions(+), 105 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index efc44cf71..0d06f399d 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -97,7 +97,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. - A real purchase requires **both** `dry_run=false` **and** `confirm=true`. `dry_run=false` with `confirm=false` (or vice versa) is refused with a structured error, not silently downgraded to a preview or silently ignored. - 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 plus a discriminator, so retrying an identical tool call after a network error dedupes at the provider instead of buying twice. By default the discriminator is the current hour (an automatic time bucket), not just the parameters -- so a rapid retry within the same hour still dedupes correctly, but two calls with otherwise-identical parameters made further apart (e.g. "buy 3 RIs now" and "buy 3 more next week") are treated as genuinely separate purchases and get distinct tokens, rather than the second one silently colliding with and being skipped as a "retry" of the first. Pass the same `idempotency_nonce` value on both calls to force strict dedup regardless of how much time elapses between them. +- 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. - Provider/SDK failures surface their full error text back to the caller; nothing is swallowed. ## Caveats and known gaps @@ -115,5 +115,5 @@ These are pre-existing behaviours in the underlying purchase clients, not someth - **"provider ... is not configured" / credential errors**: confirm the relevant environment variable(s) from [Configure credentials](#configure-credentials) are set in the shell (or the client's `env` block) that launches `cudly-mcp`, or pass the matching per-call override (`aws_profile` / `azure_subscription_id` / `gcp_project_id`). - **Azure/GCP purchase calls appear to hang**: Azure Reservations and GCP Compute Commitments both provision asynchronously after the purchase call returns; a `success=true` response means the purchase request was accepted, not necessarily that the resource is already active in the portal/console. Re-run `cudly_search_recommendations` or check the provider console if you need to confirm activation state. -- **Rate limits / throttling from the cloud provider**: retry the same tool call with the same parameters -- the idempotency token guarantees a retry cannot double-purchase, as long as the retry happens within the same automatic time bucket (one hour by default; see [Safety model](#safety-model)). For a deliberate retry across a longer gap, pass the same `idempotency_nonce` value on both calls to force the same idempotency key regardless of elapsed time. +- **Rate limits / throttling from the cloud provider**: retry the same tool call with the same parameters -- the idempotency token guarantees a retry cannot double-purchase no matter how long you wait before retrying (see [Safety model](#safety-model)). If you genuinely want a second, separate purchase with the same parameters instead of a retry, pass a fresh `idempotency_nonce` value. - **"invalid ... must be one of ..." errors**: every enum-typed parameter (term, payment option, engine, az_config, sp_type, scope, tenancy, platform) is validated against an explicit allow-list; call `cudly_list_commitment_actions` or re-check this README's per-tool schema for the exact accepted values. diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go index 99b78f5eb..7aa2a9bfa 100644 --- a/mcp/tools/aws_ec2_ri.go +++ b/mcp/tools/aws_ec2_ri.go @@ -37,7 +37,7 @@ type ec2RIPurchaseArgs struct { AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` - IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` + 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"` } type awsEC2RIPurchaseTool struct { diff --git a/mcp/tools/aws_elasticache_ri.go b/mcp/tools/aws_elasticache_ri.go index 84c3a595d..d9b7c1927 100644 --- a/mcp/tools/aws_elasticache_ri.go +++ b/mcp/tools/aws_elasticache_ri.go @@ -30,7 +30,7 @@ type elasticacheRIPurchaseArgs struct { AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` - IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` + 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"` } type awsElastiCacheRIPurchaseTool struct { diff --git a/mcp/tools/aws_rds_ri.go b/mcp/tools/aws_rds_ri.go index 9b2560da2..e6975c459 100644 --- a/mcp/tools/aws_rds_ri.go +++ b/mcp/tools/aws_rds_ri.go @@ -33,7 +33,7 @@ type rdsRIPurchaseArgs struct { AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` - IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` + 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"` } type awsRDSRIPurchaseTool struct { diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index 9aaf8f6aa..90d46b26c 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -44,7 +44,7 @@ type savingsPlansPurchaseArgs struct { AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` - IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` + 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"` } type awsSavingsPlansPurchaseTool struct { diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index 25bbb9467..77ee0f288 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -37,7 +37,7 @@ type simpleAWSRIPurchaseArgs struct { AWSProfile string `json:"aws_profile,omitempty" jsonschema:"AWS named profile override (~/.aws/config); default uses ambient credentials"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` - IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` + 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"` } type simpleAWSRIPurchaseTool struct { diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index 6cdb4ae20..e01988b23 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -42,7 +42,7 @@ type azureComputeRIPurchaseArgs struct { AzureSubscriptionID string `json:"azure_subscription_id,omitempty" jsonschema:"Azure subscription ID override; default uses AZURE_SUBSCRIPTION_ID"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` - IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` + 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"` } type azureComputeRIPurchaseTool struct { diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index a2a2f44c4..c881bedce 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -33,7 +33,7 @@ type gcpComputeEngineCUDPurchaseArgs struct { GCPProjectID string `json:"gcp_project_id,omitempty" jsonschema:"GCP project ID override; default uses ambient project"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` - IdempotencyNonce string `json:"idempotency_nonce,omitempty" jsonschema:"optional caller-chosen token; passing the SAME value on a retry of this exact call forces the same idempotency key so the provider dedupes it as a retry (e.g. after a network timeout); omitting it (the default) means two calls with otherwise-identical parameters are treated as genuinely separate purchases and get distinct keys"` + 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"` } type gcpComputeEngineCUDPurchaseTool struct { diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index bb92f6d1b..2e6b8ccdb 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -58,13 +58,11 @@ type PurchaseRequest struct { Confirm bool ResolveClient ResolveClientFunc - // Nonce is optional; when non-empty it is used verbatim as the - // idempotency discriminator instead of the automatic time bucket, - // letting a caller force two calls to dedupe as the same purchase - // regardless of elapsed time. When empty (the default), the - // discriminator is derived automatically from the current time bucket - // so identical-looking-but-actually-separate purchases don't silently - // collide. See idempotencyKeyFor. + // Nonce is optional. When non-empty, this call is treated as a + // DISTINCT purchase from an otherwise-identical one (authorizes a + // deliberate repeat, e.g. "buy 3 more RIs" on top of an earlier "buy 3 + // RIs" with the same parameters). When empty (the default), identical + // purchases dedupe so retries never double-buy. See idempotencyKeyFor. Nonce string } @@ -125,22 +123,6 @@ func nonZeroCostPtr(v float64) *float64 { return &v } -// idempotencyBucket is the width of the automatic time-based discriminator -// folded into the idempotency key when the caller does not supply an -// explicit idempotency_nonce (see idempotencyKeyFor). Wide enough that a -// caller's own rapid retry of a recent call (e.g. after a network timeout -- -// this guard's original purpose) still lands in the same bucket and dedupes -// as before; narrow enough that two genuinely separate purchases made hours -// or days apart (e.g. "buy 3 RIs now, buy 3 more next week" -- the -// adversarial review finding this constant fixes) never collide by -// accident. -const idempotencyBucket = time.Hour - -// idempotencyClock is a seam so tests can freeze "now" and assert exact -// bucket-boundary behavior deterministically instead of depending on -// wall-clock timing. -var idempotencyClock = time.Now - // idempotencyKeyFor derives a stable per-request key from every field that // identifies what is being bought: provider, region, service, resource type, // count, term, payment option, plus every service-specific dimension held in @@ -163,27 +145,28 @@ var idempotencyClock = time.Now // package populates it today, so folding it in would add an always-empty, // misleading key component rather than real discrimination. // -// A second issue found in adversarial review: every field above identifies -// WHAT is being bought, not WHEN -- so two genuinely distinct purchases with -// identical parameters (a $5/hr Compute Savings Plan followed a week later -// by a genuinely separate $5/hr purchase) previously collided on the same -// token, and providers/aws/services/ec2/client.go's findRIByIdempotencyToken -// silently treated the second, real purchase as a retry of the first and -// skipped it. nonce and idempotencyBucket fix this: an explicit -// caller-supplied nonce is used verbatim as the discriminator when present -// (letting a caller force strict, long-lived dedup across an arbitrary gap); -// otherwise the discriminator falls back to the current idempotencyBucket- -// wide time bucket, so a rapid retry within the same bucket still dedupes as -// before, but two calls separated by more than a bucket width derive -// different keys and both purchases go through. +// This function is deliberately fail-safe with respect to time: it folds in +// no clock reading of any kind. When nonce is empty (the default), two calls +// with identical dimensions ALWAYS derive the same key, no matter how far +// apart in time they happen -- so a retry that straddles any time boundary +// still dedupes at the provider instead of risking a double purchase. An +// earlier version of this function instead folded in an automatic hourly +// time bucket to distinguish "buy 3 RIs now" from a genuinely separate "buy +// 3 more next week" with identical parameters; that inverted the safety +// direction of this money path, because a retry that happened to straddle +// an hour boundary (e.g. a slow request issued at 12:59:58 retried at +// 13:00:02) derived a different key and could double-buy. The worst case of +// the current, fail-safe default is a skipped intentional repeat -- a +// caller who genuinely wants a second, identical purchase must say so +// explicitly. nonce is that explicit opt-in: when the caller supplies a +// non-empty nonce, it is folded into the key so an otherwise-identical +// purchase becomes a distinct one (e.g. "buy 3 now" then "buy 3 more next +// week" by passing a fresh nonce on the second call); the same nonce plus +// the same dimensions still dedupes a nonce'd retry. func idempotencyKeyFor(region string, rec common.Recommendation, nonce string) string { - discriminator := nonce - if discriminator == "" { - discriminator = idempotencyClock().UTC().Truncate(idempotencyBucket).Format(time.RFC3339) - } return fmt.Sprintf("mcp:%s:%s:%s:%s:%d:%s:%s:%s:%s", rec.Provider, region, rec.Service, rec.ResourceType, rec.Count, rec.Term, rec.PaymentOption, - detailsKeyComponent(rec.Details), discriminator) + detailsKeyComponent(rec.Details), nonce) } // detailsKeyComponent returns a canonical, deterministic encoding of every diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index dad1a6fa2..3edc398c3 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -14,18 +13,6 @@ import ( "github.com/LeanerCloud/CUDly/pkg/provider" ) -// freezeIdempotencyClock overrides idempotencyClock for the duration of a -// test so idempotencyKeyFor's automatic time-bucket discriminator is -// deterministic rather than depending on wall-clock timing. Returns a -// restore func; callers should defer it (or call it explicitly if freezing -// multiple distinct instants within one test). -func freezeIdempotencyClock(t *testing.T, at time.Time) func() { - t.Helper() - orig := idempotencyClock - idempotencyClock = func() time.Time { return at } - return func() { idempotencyClock = orig } -} - // fakeServiceClient is a minimal provider.ServiceClient test double. Only // PurchaseCommitment is exercised by these tests; the rest of the interface // is implemented trivially to satisfy the type. @@ -378,13 +365,7 @@ func TestExecutePurchaseResolveClientErrorSurfaced(t *testing.T) { // have silently deduped the second call as a "retry" of the first instead // of buying a second, larger plan. func TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment(t *testing.T) { - // Not t.Parallel(): this test freezes the package-level idempotencyClock, - // which several other tests in this file read (via idempotencyKeyFor / - // ExecutePurchase) while running in the parallel batch. Staying in the - // serial phase means the freeze/restore cycle completes before any - // parallel test's body executes, so there is no concurrent access to the - // shared var. - defer freezeIdempotencyClock(t, time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))() + t.Parallel() cheapArgs := validSavingsPlansArgs() cheapArgs.HourlyCommitment = 5 @@ -409,8 +390,7 @@ func TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment(t *testing.T) { // product-affecting dimension carried in rec.Details, and the pre-fix key // derivation ignored Details entirely. func TestIdempotencyKeyDistinguishesEC2Platform(t *testing.T) { - // Not t.Parallel(): see TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment. - defer freezeIdempotencyClock(t, time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))() + t.Parallel() linuxArgs := validEC2Args() linuxArgs.Platform = "Linux/UNIX" @@ -428,43 +408,49 @@ func TestIdempotencyKeyDistinguishesEC2Platform(t *testing.T) { "a Linux and a Windows EC2 RI purchase must not derive the same idempotency key") } -// TestIdempotencyKeyNonceOverridesTimeBucket proves the three scenarios the -// nonce/time-bucket design must satisfy: an explicit nonce always wins over -// elapsed time (strict caller-controlled dedup); with no nonce, two calls -// separated by more than idempotencyBucket derive different keys (genuinely -// separate purchases don't collide); with no nonce, two calls in the same -// instant (the same bucket) derive the same key (a rapid retry still -// dedupes, preserving this guard's original purpose). -func TestIdempotencyKeyNonceOverridesTimeBucket(t *testing.T) { - // Not t.Parallel(): see TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment. +// TestIdempotencyKeySameDimensionsNoNonceAlwaysMatch is the regression guard +// for the fail-safe design: identical purchase dimensions with no nonce must +// ALWAYS derive the same key, with no dependence on time at all. This is the +// inverse of, and replaces, a prior design that folded an automatic hourly +// time bucket into the key when no nonce was supplied -- under that design a +// retry that happened to straddle an hour boundary (e.g. issued at +// 12:59:58, retried four seconds later at 13:00:02) derived a DIFFERENT key, +// so the provider could treat the retry as a brand new purchase instead of +// deduping it, resulting in a double purchase. idempotencyKeyFor no longer +// reads a clock at all when nonce is empty, so this is not merely "same +// bucket" but unconditionally the same key for the life of the process. +func TestIdempotencyKeySameDimensionsNoNonceAlwaysMatch(t *testing.T) { + t.Parallel() rec := testRecommendation() region := "us-east-1" - weekApart1 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - weekApart2 := weekApart1.Add(7 * 24 * time.Hour) - - t.Run("same explicit nonce wins over a week apart", func(t *testing.T) { - defer freezeIdempotencyClock(t, weekApart1)() - key1 := idempotencyKeyFor(region, rec, "retry-abc") - freezeIdempotencyClock(t, weekApart2) - key2 := idempotencyKeyFor(region, rec, "retry-abc") - assert.Equal(t, key1, key2, "an explicit shared nonce must dedupe regardless of elapsed time") - }) - t.Run("no nonce and a week apart derives different keys", func(t *testing.T) { - defer freezeIdempotencyClock(t, weekApart1)() - key1 := idempotencyKeyFor(region, rec, "") - freezeIdempotencyClock(t, weekApart2) - key2 := idempotencyKeyFor(region, rec, "") - assert.NotEqual(t, key1, key2, - "two genuinely separate purchases a week apart must not collide when no nonce is supplied") - }) + key1 := idempotencyKeyFor(region, rec, "") + key2 := idempotencyKeyFor(region, rec, "") + assert.Equal(t, key1, key2, + "identical dimensions with no nonce must always derive the same key, so a retry never double-buys") +} - t.Run("no nonce and the same instant derives the same key", func(t *testing.T) { - defer freezeIdempotencyClock(t, weekApart1)() - key1 := idempotencyKeyFor(region, rec, "") - key2 := idempotencyKeyFor(region, rec, "") - assert.Equal(t, key1, key2, "a rapid retry within the same time bucket must still dedupe") - }) +// TestIdempotencyKeyNonceAuthorizesDistinctRepeat proves the nonce is the +// caller's explicit opt-in to a deliberate repeat purchase: a non-empty +// nonce derives a key different from the no-nonce key and from a different +// nonce, but the SAME nonce with the SAME dimensions still dedupes (a +// nonce'd retry is still safe against double-buying). +func TestIdempotencyKeyNonceAuthorizesDistinctRepeat(t *testing.T) { + t.Parallel() + rec := testRecommendation() + region := "us-east-1" + + noNonceKey := idempotencyKeyFor(region, rec, "") + nonceAKey1 := idempotencyKeyFor(region, rec, "nonce-a") + nonceAKey2 := idempotencyKeyFor(region, rec, "nonce-a") + nonceBKey := idempotencyKeyFor(region, rec, "nonce-b") + + assert.NotEqual(t, noNonceKey, nonceAKey1, + "supplying a nonce must authorize a purchase distinct from the no-nonce default") + assert.NotEqual(t, nonceAKey1, nonceBKey, + "two different nonces must derive two different keys") + assert.Equal(t, nonceAKey1, nonceAKey2, + "the same nonce with the same dimensions must still dedupe a nonce'd retry") } // TestExecutePurchaseNonceThreadedThroughToToken proves PurchaseRequest.Nonce From bc28b7fb9aa2c0eb757ce1cd2b028b4b8a747b4a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 17:18:50 +0200 Subject: [PATCH 36/83] fix(mcp): drop partial-upfront from Azure RI payment_option schema Azure has no partial-upfront billing plan; the MCP tool already rejected it at runtime but the schema enum still advertised it as a valid choice, inviting a call the tool could only ever refuse. Restrict the Azure compute RI tool's payment_option enum to all-upfront/no-upfront and keep the runtime rejection as defense in depth. AWS tools keep their unrestricted enum since AWS does support partial-upfront. Adds an end-to-end MCP protocol test asserting the advertised schema excludes partial-upfront (confirmed failing before the fix). --- mcp/server_test.go | 52 +++++++++++++++++++++++++++++++++++ mcp/tools/azure_compute_ri.go | 10 +++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/mcp/server_test.go b/mcp/server_test.go index 58d466270..f2b44964c 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -169,3 +169,55 @@ func TestRealPurchaseToolsDocumentMoneyImpactAndDryRun(t *testing.T) { "tool %q description must recommend dry_run first: %q", d.Name, d.Description) } } + +// TestAzureComputeRIPurchaseSchemaExcludesPartialUpfront proves the +// cudly_azure_compute_ri_purchase tool's advertised payment_option enum +// never offers partial-upfront: Azure has no partial-upfront billing plan +// (mcp/tools/azure_compute_ri.go rejects it at runtime as defense in depth), +// so the schema itself must not invite a caller to pick a value the tool +// can only ever refuse. Drives the real MCP protocol (ListTools), not a +// bare Go function call, so it catches a regression in what a real client +// actually sees, not just in the tool's internal validation. +func TestAzureComputeRIPurchaseSchemaExcludesPartialUpfront(t *testing.T) { + t.Parallel() + ctx := context.Background() + + server, err := NewServer("test") + require.NoError(t, err) + + clientTransport, serverTransport := gosdk.NewInMemoryTransports() + go func() { + _ = server.Run(ctx, serverTransport) + }() + + client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + defer session.Close() + + toolsList, err := session.ListTools(ctx, nil) + require.NoError(t, err) + + var azureTool *gosdk.Tool + for _, tl := range toolsList.Tools { + if tl.Name == "cudly_azure_compute_ri_purchase" { + azureTool = tl + break + } + } + require.NotNil(t, azureTool, "cudly_azure_compute_ri_purchase must be registered") + + schema, ok := azureTool.InputSchema.(map[string]any) + require.True(t, ok, "client-side InputSchema must be the default JSON marshaling (map[string]any)") + properties, ok := schema["properties"].(map[string]any) + require.True(t, ok) + paymentOption, ok := properties["payment_option"].(map[string]any) + require.True(t, ok) + enum, ok := paymentOption["enum"].([]any) + require.True(t, ok) + + assert.NotContains(t, enum, "partial-upfront", + "Azure schema must not advertise partial-upfront: Azure has no such billing plan") + assert.Contains(t, enum, "all-upfront") + assert.Contains(t, enum, "no-upfront") +} diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index e01988b23..45a3429a3 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -71,8 +71,14 @@ func (t *azureComputeRIPurchaseTool) Descriptor() Descriptor { func (t *azureComputeRIPurchaseTool) Register(s *mcp.Server) error { schema, err := BuildInputSchema[azureComputeRIPurchaseArgs](map[string]FieldOverride{ - "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, - "payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionPartialUpfront), string(PaymentOptionNoUpfront)}, Default: string(PaymentOptionNoUpfront)}, + "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, + // Azure has no partial-upfront billing plan (see + // azureComputeRIPurchaseDescription and azureComputeRecommendationFromArgs + // below), so this tool's schema advertises only the two values Azure + // actually honors. The runtime check in azureComputeRecommendationFromArgs + // still rejects partial-upfront explicitly, as defense in depth for a + // caller that bypasses the schema. + "payment_option": {Enum: []any{string(PaymentOptionAllUpfront), string(PaymentOptionNoUpfront)}, Default: string(PaymentOptionNoUpfront)}, "dry_run": {Default: true}, "confirm": {Default: false}, }) From 28d69454035e6a95084d5ae391ab29155320779f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 17:19:09 +0200 Subject: [PATCH 37/83] fix(mcp): trim whitespace-only region before Savings Plans account-level default The account-level Savings Plans fallback only triggered on region == "", so a whitespace-only region (e.g. " ") skipped the savingsPlansAccountLevelRegion default and threaded the raw whitespace into resolveClient instead. Match the EC2Instance branch two lines up, which already trims before checking for blank. Adds a regression test with region=" " for an account-level sp_type (Compute), confirmed failing before the fix. --- mcp/tools/aws_savingsplans.go | 2 +- mcp/tools/aws_savingsplans_test.go | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index 90d46b26c..64bfb381e 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -162,7 +162,7 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo } region = args.Region - if region == "" { + if strings.TrimSpace(region) == "" { region = savingsPlansAccountLevelRegion } diff --git a/mcp/tools/aws_savingsplans_test.go b/mcp/tools/aws_savingsplans_test.go index 76bf4caaf..95c7cf58e 100644 --- a/mcp/tools/aws_savingsplans_test.go +++ b/mcp/tools/aws_savingsplans_test.go @@ -36,6 +36,23 @@ func TestSavingsPlanRecommendationFromArgsAccountLevel(t *testing.T) { assert.InDelta(t, 10.50, details.HourlyCommitment, 0.001) } +// TestSavingsPlanRecommendationFromArgsAccountLevelWhitespaceRegion proves a +// whitespace-only region (e.g. " ") for an account-level sp_type (Compute, +// SageMaker, Database) still falls back to savingsPlansAccountLevelRegion, +// the same as an empty region does. Before the fix, the fallback only +// triggered on region == "", so a whitespace-only region threaded the raw +// " " value into resolveClient instead of the account-level default. +func TestSavingsPlanRecommendationFromArgsAccountLevelWhitespaceRegion(t *testing.T) { + t.Parallel() + args := validSavingsPlansArgs() + args.Region = " " + rec, region, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, savingsPlansAccountLevelRegion, region, + "whitespace-only region must resolve to the account-level default, not be threaded through as-is") + assert.Equal(t, common.ServiceSavingsPlansCompute, rec.Service) +} + func TestSavingsPlanRecommendationFromArgsEC2InstanceRequiresRegion(t *testing.T) { t.Parallel() args := validSavingsPlansArgs() From f97fa65eebb450a314ef04269140088685e18b46 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 21:39:06 +0200 Subject: [PATCH 38/83] fix(mcp): scope Savings Plans Details.Region/InstanceFamily to EC2Instance common.SavingsPlanDetails documents Region and InstanceFamily as only populated for EC2Instance plans, but savingsPlanRecommendationFromArgs set both unconditionally from caller args. A Compute/SageMaker/Database (account -level, family-agnostic) request with a caller-supplied region or instance_family leaked that value into Details, violating the documented invariant that other Details consumers rely on. Populate InstanceFamily and Region only when sp_type=EC2Instance, matching the validation that already requires both fields for that type. --- mcp/tools/aws_savingsplans.go | 21 ++++++++--- mcp/tools/aws_savingsplans_test.go | 59 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index 64bfb381e..ed4001199 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -175,6 +175,20 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo // depth on top of the ValidateSPType check above). service := savingsplans.ServiceTypeForPlanType(spTypes.SavingsPlanType(spType)) + details := &common.SavingsPlanDetails{ + PlanType: string(spType), + HourlyCommitment: args.HourlyCommitment, + } + // InstanceFamily and Region are only meaningful for EC2Instance plans + // (common.SavingsPlanDetails documents both as "only populated for + // EC2Instance"); leaving them unset for Compute/SageMaker/Database keeps + // that contract instead of leaking a caller-supplied region/family into + // an account-level, family-agnostic plan's Details. + if spType == SPTypeEC2Instance { + details.InstanceFamily = args.InstanceFamily + details.Region = args.Region + } + rec = common.Recommendation{ Provider: common.ProviderAWS, Service: service, @@ -182,12 +196,7 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo CommitmentType: common.CommitmentSavingsPlan, Term: term.RecommendationTerm(), PaymentOption: string(paymentOption), - Details: &common.SavingsPlanDetails{ - PlanType: string(spType), - HourlyCommitment: args.HourlyCommitment, - InstanceFamily: args.InstanceFamily, - Region: args.Region, - }, + Details: details, } dryRun, confirm = true, false diff --git a/mcp/tools/aws_savingsplans_test.go b/mcp/tools/aws_savingsplans_test.go index 95c7cf58e..49d37fec3 100644 --- a/mcp/tools/aws_savingsplans_test.go +++ b/mcp/tools/aws_savingsplans_test.go @@ -157,6 +157,65 @@ func TestSavingsPlanRecommendationFromArgsInstanceFamilyOptionalForOtherTypes(t }) } +// TestSavingsPlanRecommendationFromArgsDetailsRegionFamilyOnlyForEC2Instance +// is the regression guard for the CodeRabbit money-path finding: Details.Region +// and Details.InstanceFamily are documented on common.SavingsPlanDetails as +// "only populated for EC2Instance"; providers/aws/services/savingsplans/client.go +// uses Details.Region as a DescribeSavingsPlansOfferings filter. A +// caller-supplied region/instance_family for an account-level sp_type +// (Compute, SageMaker, Database) must NOT leak into Details -- before the +// fix, both fields were populated unconditionally from args, regardless of +// sp_type. +func TestSavingsPlanRecommendationFromArgsDetailsRegionFamilyOnlyForEC2Instance(t *testing.T) { + t.Parallel() + + for _, spType := range []string{"Compute", "SageMaker"} { + t.Run(spType, func(t *testing.T) { + args := validSavingsPlansArgs() + args.SPType = spType + args.Region = "eu-west-1" + args.InstanceFamily = "m5" + + rec, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err) + details, ok := rec.Details.(*common.SavingsPlanDetails) + require.True(t, ok) + assert.Empty(t, details.Region, "account-level sp_type=%s must not carry a caller-supplied region into Details", spType) + assert.Empty(t, details.InstanceFamily, "account-level sp_type=%s must not carry a caller-supplied instance_family into Details", spType) + }) + } + + t.Run("Database", func(t *testing.T) { + args := validSavingsPlansArgs() + args.SPType = "Database" + args.TermYears = 1 + args.PaymentOption = "no-upfront" + args.Region = "eu-west-1" + args.InstanceFamily = "m5" + + rec, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err) + details, ok := rec.Details.(*common.SavingsPlanDetails) + require.True(t, ok) + assert.Empty(t, details.Region, "sp_type=Database must not carry a caller-supplied region into Details") + assert.Empty(t, details.InstanceFamily, "sp_type=Database must not carry a caller-supplied instance_family into Details") + }) + + t.Run("EC2Instance still carries both", func(t *testing.T) { + args := validSavingsPlansArgs() + args.SPType = "EC2Instance" + args.Region = "us-east-1" + args.InstanceFamily = "m5" + + rec, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err) + details, ok := rec.Details.(*common.SavingsPlanDetails) + require.True(t, ok) + assert.Equal(t, "us-east-1", details.Region) + assert.Equal(t, "m5", details.InstanceFamily) + }) +} + func TestSavingsPlanRecommendationFromArgsInvalid(t *testing.T) { t.Parallel() cases := []struct { From f610e01091cf51c2fe9b62177355ef54a8f23daa Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 21:39:37 +0200 Subject: [PATCH 39/83] fix(azure): validate payment option before Microsoft.Capacity registration PurchaseCommitment called ensureCapacityProviderRegistered (a real ARM resource-provider GET, and a POST to register when unregistered) before buildReservationBody validated the payment option via BillingPlanForPaymentOption. An invalid or empty PaymentOption still triggered that side-effecting call before being rejected. Resolve the billing plan via BillingPlanForPaymentOption first and thread the parsed value into buildReservationBody, so an invalid payment option fails loud before any side effect and the plan is no longer parsed twice. --- providers/azure/services/compute/client.go | 21 +++++-- .../azure/services/compute/client_test.go | 61 +++++++++++++++---- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/providers/azure/services/compute/client.go b/providers/azure/services/compute/client.go index 765ff55d6..011873e49 100644 --- a/providers/azure/services/compute/client.go +++ b/providers/azure/services/compute/client.go @@ -407,19 +407,18 @@ func (c *ComputeClient) triggerCapacityProviderRegistration(ctx context.Context, // buildReservationBody builds the JSON body for a reservation purchase request. // The same body is sent to both calculatePrice and purchase endpoints (issue #677). +// billingPlan is resolved by the caller (PurchaseCommitment) via +// reservations.BillingPlanForPaymentOption before any side-effecting call is +// made, so it is threaded in here rather than re-derived from rec.PaymentOption. // The purchase-automation and cudly-idempotency-token tags are attached via // reservations.ApplyPurchaseTags so the resulting reservation is identifiable // in the portal AND a re-driven purchase can find it via tag lookup before // buying a duplicate (issue #721). -func (c *ComputeClient) buildReservationBody(rec common.Recommendation, source, idempotencyToken string) ([]byte, error) { +func (c *ComputeClient) buildReservationBody(rec common.Recommendation, billingPlan armreservations.ReservationBillingPlan, source, idempotencyToken string) ([]byte, error) { termYears, err := reservations.ParseTermYears(rec.Term) if err != nil { return nil, err } - billingPlan, err := reservations.BillingPlanForPaymentOption(rec.PaymentOption) - if err != nil { - return nil, err - } requestBody := map[string]interface{}{ "sku": map[string]string{"name": rec.ResourceType}, "location": c.region, @@ -483,10 +482,20 @@ func (c *ComputeClient) PurchaseCommitment(ctx context.Context, rec common.Recom return result, result.Error } + // Validate the payment option BEFORE any side-effecting call. Microsoft.Capacity + // provider registration below is a real ARM operation (a GET, and a POST to + // register when unregistered); an invalid/empty PaymentOption must be rejected + // here first so a doomed purchase never triggers it. + billingPlan, err := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + if err != nil { + result.Error = err + return result, result.Error + } + // Ensure Microsoft.Capacity provider is registered (cached after first call). c.ensureCapacityProviderRegistered(ctx) - bodyBytes, err := c.buildReservationBody(rec, opts.Source, opts.IdempotencyToken) + bodyBytes, err := c.buildReservationBody(rec, billingPlan, opts.Source, opts.IdempotencyToken) if err != nil { result.Error = fmt.Errorf("failed to marshal request: %w", err) return result, result.Error diff --git a/providers/azure/services/compute/client_test.go b/providers/azure/services/compute/client_test.go index feb89879c..1e406eaed 100644 --- a/providers/azure/services/compute/client_test.go +++ b/providers/azure/services/compute/client_test.go @@ -22,6 +22,7 @@ import ( "github.com/LeanerCloud/CUDly/pkg/common" "github.com/LeanerCloud/CUDly/providers/azure/mocks" + "github.com/LeanerCloud/CUDly/providers/azure/services/internal/reservations" ) func TestNewClient(t *testing.T) { @@ -922,6 +923,30 @@ func TestComputeClient_PurchaseCommitment_RequiresSource(t *testing.T) { mockHTTP.AssertNotCalled(t, "Do", mock.Anything) } +// TestComputeClient_PurchaseCommitment_RejectsInvalidPaymentOptionBeforeSideEffects +// pins the fix ordering: an invalid/empty PaymentOption must be rejected +// BEFORE PurchaseCommitment ever calls ensureCapacityProviderRegistered, which +// issues a real ARM GET (and potentially a POST to register) against +// Microsoft.Capacity. Before this fix, the provider-registration check ran +// first, so a doomed purchase (bad payment option) still triggered that +// side-effecting ARM call. mockHTTP.AssertNotCalled proves zero HTTP calls +// were made -- this test fails pre-fix, because the old code issued the +// Microsoft.Capacity GET before buildReservationBody ever validated +// PaymentOption. +func TestComputeClient_PurchaseCommitment_RejectsInvalidPaymentOptionBeforeSideEffects(t *testing.T) { + ctx := context.Background() + mockHTTP := &mocks.MockHTTPClient{} + mockCred := &MockTokenCredential{token: "test-token"} + client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) + + rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Term: "1yr", Count: 1, PaymentOption: "partial-upfront"} + result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), "partial-upfront has no azure equivalent") + mockHTTP.AssertNotCalled(t, "Do", mock.Anything) +} + // TestComputeClient_ConvertAzureVMRecommendation_NilGuards pins the new // contract: unusable SDK payloads (nil, wrong concrete type, nil Properties) // produce a nil *Recommendation so the caller can filter it out. Before @@ -1004,9 +1029,14 @@ func TestFetchAzurePricing_WrapperSmokeTest(t *testing.T) { // "upfront"/"monthly" or the CLI/MCP's "all-upfront"/"no-upfront"). An empty // or unrecognized value (including "partial-upfront", which Azure cannot // express) must fail loud rather than silently defaulting to Upfront -// (feedback_no_silent_fallbacks) -- before this fix, buildReservationBody -// never set billingPlan at all, so every purchase silently defaulted to -// Azure's Upfront behavior regardless of what the caller requested. +// (feedback_no_silent_fallbacks). +// +// buildReservationBody itself no longer resolves the billing plan: the +// caller (PurchaseCommitment) validates rec.PaymentOption via +// reservations.BillingPlanForPaymentOption before any side-effecting call +// (Microsoft.Capacity provider registration) and threads the result in, so +// this test mirrors that same call order rather than duplicating validation +// inside buildReservationBody. func TestBuildReservationBody_BillingPlan(t *testing.T) { cases := []struct { name string @@ -1024,18 +1054,19 @@ func TestBuildReservationBody_BillingPlan(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - c := &ComputeClient{region: "eastus", subscriptionID: "sub-abc"} - rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr", PaymentOption: tc.paymentOption} - - body, err := c.buildReservationBody(rec, common.PurchaseSourceWeb, "") - + billingPlan, err := reservations.BillingPlanForPaymentOption(tc.paymentOption) if tc.wantErrSub != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErrSub) - assert.Nil(t, body) return } require.NoError(t, err) + + c := &ComputeClient{region: "eastus", subscriptionID: "sub-abc"} + rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr", PaymentOption: tc.paymentOption} + + body, err := c.buildReservationBody(rec, billingPlan, common.PurchaseSourceWeb, "") + require.NoError(t, err) var got map[string]interface{} require.NoError(t, json.Unmarshal(body, &got)) props, ok := got["properties"].(map[string]interface{}) @@ -1048,8 +1079,10 @@ func TestBuildReservationBody_BillingPlan(t *testing.T) { func TestBuildReservationBody_IncludesPurchaseAutomationTag(t *testing.T) { c := &ComputeClient{region: "eastus", subscriptionID: "sub-abc"} rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr", PaymentOption: "no-upfront"} + billingPlan, err := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + require.NoError(t, err) - body, err := c.buildReservationBody(rec, common.PurchaseSourceWeb, "") + body, err := c.buildReservationBody(rec, billingPlan, common.PurchaseSourceWeb, "") require.NoError(t, err) var got map[string]interface{} @@ -1062,8 +1095,10 @@ func TestBuildReservationBody_IncludesPurchaseAutomationTag(t *testing.T) { func TestBuildReservationBody_OmitsTagsWhenSourceAndTokenEmpty(t *testing.T) { c := &ComputeClient{region: "eastus", subscriptionID: "sub-abc"} rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr", PaymentOption: "no-upfront"} + billingPlan, err := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + require.NoError(t, err) - body, err := c.buildReservationBody(rec, "", "") + body, err := c.buildReservationBody(rec, billingPlan, "", "") require.NoError(t, err) var got map[string]interface{} @@ -1081,8 +1116,10 @@ func TestBuildReservationBody_IncludesIdempotencyTokenTag(t *testing.T) { c := &ComputeClient{region: "eastus", subscriptionID: "sub-abc"} rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr", PaymentOption: "no-upfront"} token := common.DeriveIdempotencyToken("exec-721-compute", 0) + billingPlan, err := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + require.NoError(t, err) - body, err := c.buildReservationBody(rec, common.PurchaseSourceWeb, token) + body, err := c.buildReservationBody(rec, billingPlan, common.PurchaseSourceWeb, token) require.NoError(t, err) var got map[string]interface{} From 88a1af5780a97ac8132b996b4b18164f195b58da Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 21:40:36 +0200 Subject: [PATCH 40/83] fix(mcp): trim surrounding whitespace on required identifier fields requireNonBlank rejected an all-whitespace value but let a value with surrounding whitespace (e.g. " us-east-1 ") pass through unchanged into rec.Region/rec.ResourceType/Details and, for region, into resolveClient's ProviderConfig.Region and GetServiceClient call. requireNonBlank now returns the trimmed form on success. Every purchase tool (EC2, RDS, ElastiCache, the OpenSearch/Redshift/MemoryDB group, Azure VM, GCP Compute Engine) stores and threads the trimmed value through rec.Region/rec.ResourceType/Details and, for region, returns it from the recommendation-building function so resolveClient never resolves the provider/service client against a raw, un-trimmed value. --- mcp/tools/aws_ec2_ri.go | 47 ++++++++++-------- mcp/tools/aws_ec2_ri_test.go | 30 ++++++++++-- mcp/tools/aws_elasticache_ri.go | 58 +++++++++++----------- mcp/tools/aws_elasticache_ri_test.go | 27 +++++++++- mcp/tools/aws_rds_ri.go | 65 +++++++++++++------------ mcp/tools/aws_rds_ri_test.go | 29 ++++++++++- mcp/tools/aws_simple_ri.go | 44 ++++++++++------- mcp/tools/aws_simple_ri_test.go | 25 +++++++++- mcp/tools/azure_compute_ri.go | 43 +++++++++------- mcp/tools/azure_compute_ri_test.go | 32 +++++++++--- mcp/tools/enums.go | 25 ++++++---- mcp/tools/gcp_computeengine_cud.go | 61 +++++++++++------------ mcp/tools/gcp_computeengine_cud_test.go | 27 +++++++++- mcp/tools/purchase_test.go | 8 +-- 14 files changed, 343 insertions(+), 178 deletions(-) diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go index 7aa2a9bfa..dce1e6229 100644 --- a/mcp/tools/aws_ec2_ri.go +++ b/mcp/tools/aws_ec2_ri.go @@ -85,17 +85,17 @@ func (t *awsEC2RIPurchaseTool) Register(s *mcp.Server) error { } func (t *awsEC2RIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args ec2RIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { - rec, dryRun, confirm, err := ec2RecommendationFromArgs(args) + rec, region, dryRun, confirm, err := ec2RecommendationFromArgs(args) if err != nil { return nil, PurchaseResponse{}, err } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: args.Region, + Region: region, Recommendation: rec, DryRun: dryRun, Confirm: confirm, - ResolveClient: t.resolveClient(args), + ResolveClient: t.resolveClient(args, region), Nonce: args.IdempotencyNonce, }) if err != nil { @@ -158,42 +158,44 @@ func effectiveDryRunConfirm(args ec2RIPurchaseArgs) (dryRun, confirm bool) { } // ec2RecommendationFromArgs validates every field of args and builds the -// common.Recommendation to purchase, plus the effective dry_run/confirm -// booleans. -func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if fieldErr := requireNonBlank("region", args.Region); fieldErr != nil { - return common.Recommendation{}, false, false, fieldErr +// common.Recommendation to purchase, the effective region (trimmed of any +// surrounding whitespace), and the effective dry_run/confirm booleans. +func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (rec common.Recommendation, region string, dryRun, confirm bool, err error) { + region, err = requireNonBlank("region", args.Region) + if err != nil { + return common.Recommendation{}, "", false, false, err } - if fieldErr := requireNonBlank("instance_type", args.InstanceType); fieldErr != nil { - return common.Recommendation{}, false, false, fieldErr + instanceType, err := requireNonBlank("instance_type", args.InstanceType) + if err != nil { + return common.Recommendation{}, "", false, false, err } if args.Count <= 0 { - return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) + return common.Recommendation{}, "", false, false, fmt.Errorf("count must be > 0, got %d", args.Count) } term, err := ValidateTermYears(args.TermYears) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } paymentOption, err := ValidatePaymentOption(args.PaymentOption) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } dims, err := resolveEC2ComputeDimensions(args) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } rec = common.Recommendation{ Provider: common.ProviderAWS, Service: common.ServiceEC2, - Region: args.Region, - ResourceType: args.InstanceType, + Region: region, + ResourceType: instanceType, Count: args.Count, CommitmentType: common.CommitmentReservedInstance, Term: term.RecommendationTerm(), PaymentOption: string(paymentOption), Details: &common.ComputeDetails{ - InstanceType: args.InstanceType, + InstanceType: instanceType, Platform: string(dims.platform), Tenancy: string(dims.tenancy), Scope: string(dims.scope), @@ -201,19 +203,22 @@ func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (rec common.Recommendatio } dryRun, confirm = effectiveDryRunConfirm(args) - return rec, dryRun, confirm, nil + return rec, region, dryRun, confirm, nil } // resolveClient returns the ResolveClientFunc that ExecutePurchase invokes // only for a real purchase, so provider/credential resolution is deferred // until after the dry_run/confirm gate has already decided to execute. -func (t *awsEC2RIPurchaseTool) resolveClient(args ec2RIPurchaseArgs) ResolveClientFunc { +// region is the effective, already-validated-and-trimmed region returned by +// ec2RecommendationFromArgs -- not args.Region -- so a real purchase never +// resolves the provider/service client against a raw, un-trimmed value. +func (t *awsEC2RIPurchaseTool) resolveClient(args ec2RIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: args.Region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} prov, err := t.createProvider(string(common.ProviderAWS), cfg) if err != nil { return nil, err } - return prov.GetServiceClient(ctx, common.ServiceEC2, args.Region) + return prov.GetServiceClient(ctx, common.ServiceEC2, region) } } diff --git a/mcp/tools/aws_ec2_ri_test.go b/mcp/tools/aws_ec2_ri_test.go index a1bdc98a5..2d9e9ff9f 100644 --- a/mcp/tools/aws_ec2_ri_test.go +++ b/mcp/tools/aws_ec2_ri_test.go @@ -25,8 +25,9 @@ func validEC2Args() ec2RIPurchaseArgs { func TestEC2RecommendationFromArgsDefaults(t *testing.T) { t.Parallel() - rec, dryRun, confirm, err := ec2RecommendationFromArgs(validEC2Args()) + rec, region, dryRun, confirm, err := ec2RecommendationFromArgs(validEC2Args()) require.NoError(t, err) + assert.Equal(t, "us-east-1", region) assert.True(t, dryRun, "dry_run must default to true") assert.False(t, confirm, "confirm must default to false") assert.Equal(t, common.ProviderAWS, rec.Provider) @@ -51,7 +52,7 @@ func TestEC2RecommendationFromArgsExplicitFlags(t *testing.T) { args.Tenancy = "dedicated" args.Scope = "availability-zone" - rec, dryRun, confirm, err := ec2RecommendationFromArgs(args) + rec, _, dryRun, confirm, err := ec2RecommendationFromArgs(args) require.NoError(t, err) assert.False(t, dryRun) assert.True(t, confirm) @@ -62,6 +63,29 @@ func TestEC2RecommendationFromArgsExplicitFlags(t *testing.T) { assert.Equal(t, "availability-zone", details.Scope) } +// TestEC2RecommendationFromArgsTrimsSurroundingWhitespace is the regression +// guard for the CodeRabbit finding: requireNonBlank rejected an all-whitespace +// value but let a value with surrounding whitespace (e.g. " us-east-1 ") +// through unchanged, which then flowed into rec.Region/rec.ResourceType and +// (via the returned region) into resolveClient's ProviderConfig.Region and +// GetServiceClient call. Both the region returned to the caller and every +// identifier field on rec must be the trimmed form. +func TestEC2RecommendationFromArgsTrimsSurroundingWhitespace(t *testing.T) { + t.Parallel() + args := validEC2Args() + args.Region = " us-east-1 " + args.InstanceType = " m5.large " + + rec, region, _, _, err := ec2RecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "us-east-1", region, "returned region must be trimmed") + assert.Equal(t, "us-east-1", rec.Region, "rec.Region must be trimmed") + assert.Equal(t, "m5.large", rec.ResourceType, "rec.ResourceType must be trimmed") + details, ok := rec.Details.(*common.ComputeDetails) + require.True(t, ok) + assert.Equal(t, "m5.large", details.InstanceType, "Details.InstanceType must be trimmed") +} + func TestEC2RecommendationFromArgsMissingRequiredFields(t *testing.T) { t.Parallel() cases := []struct { @@ -85,7 +109,7 @@ func TestEC2RecommendationFromArgsMissingRequiredFields(t *testing.T) { t.Run(tc.name, func(t *testing.T) { args := validEC2Args() tc.mutate(&args) - _, _, _, err := ec2RecommendationFromArgs(args) + _, _, _, _, err := ec2RecommendationFromArgs(args) require.Error(t, err) assert.Contains(t, err.Error(), tc.errSub) }) diff --git a/mcp/tools/aws_elasticache_ri.go b/mcp/tools/aws_elasticache_ri.go index d9b7c1927..46023cfc7 100644 --- a/mcp/tools/aws_elasticache_ri.go +++ b/mcp/tools/aws_elasticache_ri.go @@ -77,17 +77,17 @@ func (t *awsElastiCacheRIPurchaseTool) Register(s *mcp.Server) error { } func (t *awsElastiCacheRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args elasticacheRIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { - rec, dryRun, confirm, err := elasticacheRecommendationFromArgs(args) + rec, region, dryRun, confirm, err := elasticacheRecommendationFromArgs(args) if err != nil { return nil, PurchaseResponse{}, err } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: args.Region, + Region: region, Recommendation: rec, DryRun: dryRun, Confirm: confirm, - ResolveClient: t.resolveClient(args), + ResolveClient: t.resolveClient(args, region), Nonce: args.IdempotencyNonce, }) if err != nil { @@ -96,51 +96,46 @@ func (t *awsElastiCacheRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTo return nil, *resp, nil } -func elasticacheRIPurchaseRequiredFields(args elasticacheRIPurchaseArgs) error { - if err := requireNonBlank("region", args.Region); err != nil { - return err +// elasticacheRecommendationFromArgs validates args and builds the +// common.Recommendation to purchase, the effective region (trimmed of any +// surrounding whitespace), and the effective dry_run/confirm booleans. +func elasticacheRecommendationFromArgs(args elasticacheRIPurchaseArgs) (rec common.Recommendation, region string, dryRun, confirm bool, err error) { + region, err = requireNonBlank("region", args.Region) + if err != nil { + return common.Recommendation{}, "", false, false, err } - if err := requireNonBlank("node_type", args.NodeType); err != nil { - return err + nodeType, err := requireNonBlank("node_type", args.NodeType) + if err != nil { + return common.Recommendation{}, "", false, false, err } if args.Count <= 0 { - return fmt.Errorf("count must be > 0, got %d", args.Count) - } - return nil -} - -// elasticacheRecommendationFromArgs validates args and builds the -// common.Recommendation to purchase, plus the effective dry_run/confirm -// booleans. -func elasticacheRecommendationFromArgs(args elasticacheRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if fieldErr := elasticacheRIPurchaseRequiredFields(args); fieldErr != nil { - return common.Recommendation{}, false, false, fieldErr + return common.Recommendation{}, "", false, false, fmt.Errorf("count must be > 0, got %d", args.Count) } term, err := ValidateTermYears(args.TermYears) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } paymentOption, err := ValidatePaymentOption(args.PaymentOption) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } engine, err := ValidateCacheEngine(args.Engine) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } rec = common.Recommendation{ Provider: common.ProviderAWS, Service: common.ServiceElastiCache, - Region: args.Region, - ResourceType: args.NodeType, + Region: region, + ResourceType: nodeType, Count: args.Count, CommitmentType: common.CommitmentReservedInstance, Term: term.RecommendationTerm(), PaymentOption: string(paymentOption), Details: &common.CacheDetails{ Engine: string(engine), - NodeType: args.NodeType, + NodeType: nodeType, }, } @@ -151,16 +146,21 @@ func elasticacheRecommendationFromArgs(args elasticacheRIPurchaseArgs) (rec comm if args.Confirm != nil { confirm = *args.Confirm } - return rec, dryRun, confirm, nil + return rec, region, dryRun, confirm, nil } -func (t *awsElastiCacheRIPurchaseTool) resolveClient(args elasticacheRIPurchaseArgs) ResolveClientFunc { +// resolveClient returns the ResolveClientFunc that ExecutePurchase invokes +// only for a real purchase. region is the effective, already-validated-and- +// trimmed region returned by elasticacheRecommendationFromArgs -- not +// args.Region -- so a real purchase never resolves the provider/service +// client against a raw, un-trimmed value. +func (t *awsElastiCacheRIPurchaseTool) resolveClient(args elasticacheRIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: args.Region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} prov, err := t.createProvider(string(common.ProviderAWS), cfg) if err != nil { return nil, err } - return prov.GetServiceClient(ctx, common.ServiceElastiCache, args.Region) + return prov.GetServiceClient(ctx, common.ServiceElastiCache, region) } } diff --git a/mcp/tools/aws_elasticache_ri_test.go b/mcp/tools/aws_elasticache_ri_test.go index 598c3e89a..acb435789 100644 --- a/mcp/tools/aws_elasticache_ri_test.go +++ b/mcp/tools/aws_elasticache_ri_test.go @@ -24,8 +24,9 @@ func validElastiCacheArgs() elasticacheRIPurchaseArgs { func TestElastiCacheRecommendationFromArgs(t *testing.T) { t.Parallel() - rec, dryRun, confirm, err := elasticacheRecommendationFromArgs(validElastiCacheArgs()) + rec, region, dryRun, confirm, err := elasticacheRecommendationFromArgs(validElastiCacheArgs()) require.NoError(t, err) + assert.Equal(t, "us-east-1", region) assert.True(t, dryRun) assert.False(t, confirm) assert.Equal(t, common.ServiceElastiCache, rec.Service) @@ -56,13 +57,35 @@ func TestElastiCacheRecommendationFromArgsInvalid(t *testing.T) { t.Run(tc.name, func(t *testing.T) { args := validElastiCacheArgs() tc.mutate(&args) - _, _, _, err := elasticacheRecommendationFromArgs(args) + _, _, _, _, err := elasticacheRecommendationFromArgs(args) require.Error(t, err) assert.Contains(t, err.Error(), tc.errSub) }) } } +// TestElastiCacheRecommendationFromArgsTrimsSurroundingWhitespace is the +// regression guard for the CodeRabbit finding: requireNonBlank rejected an +// all-whitespace value but let a value with surrounding whitespace (e.g. +// " us-east-1 ") pass through unchanged into rec.Region/rec.ResourceType/ +// Details and the returned region (which resolveClient uses for +// ProviderConfig.Region and GetServiceClient). +func TestElastiCacheRecommendationFromArgsTrimsSurroundingWhitespace(t *testing.T) { + t.Parallel() + args := validElastiCacheArgs() + args.Region = " us-east-1 " + args.NodeType = " cache.r6g.large " + + rec, region, _, _, err := elasticacheRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "us-east-1", region, "returned region must be trimmed") + assert.Equal(t, "us-east-1", rec.Region, "rec.Region must be trimmed") + assert.Equal(t, "cache.r6g.large", rec.ResourceType, "rec.ResourceType must be trimmed") + details, ok := rec.Details.(*common.CacheDetails) + require.True(t, ok) + assert.Equal(t, "cache.r6g.large", details.NodeType, "Details.NodeType must be trimmed") +} + func TestAWSElastiCacheRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { t.Parallel() resolveCalled := false diff --git a/mcp/tools/aws_rds_ri.go b/mcp/tools/aws_rds_ri.go index e6975c459..645c9a1b8 100644 --- a/mcp/tools/aws_rds_ri.go +++ b/mcp/tools/aws_rds_ri.go @@ -80,17 +80,17 @@ func (t *awsRDSRIPurchaseTool) Register(s *mcp.Server) error { } func (t *awsRDSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args rdsRIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { - rec, dryRun, confirm, err := rdsRecommendationFromArgs(args) + rec, region, dryRun, confirm, err := rdsRecommendationFromArgs(args) if err != nil { return nil, PurchaseResponse{}, err } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: args.Region, + Region: region, Recommendation: rec, DryRun: dryRun, Confirm: confirm, - ResolveClient: t.resolveClient(args), + ResolveClient: t.resolveClient(args, region), Nonce: args.IdempotencyNonce, }) if err != nil { @@ -99,55 +99,51 @@ func (t *awsRDSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques return nil, *resp, nil } -func rdsRIPurchaseRequiredFields(args rdsRIPurchaseArgs) error { - if err := requireNonBlank("region", args.Region); err != nil { - return err +// rdsRecommendationFromArgs validates args and builds the +// common.Recommendation to purchase, the effective region (trimmed of any +// surrounding whitespace), and the effective dry_run/confirm booleans. +func rdsRecommendationFromArgs(args rdsRIPurchaseArgs) (rec common.Recommendation, region string, dryRun, confirm bool, err error) { + region, err = requireNonBlank("region", args.Region) + if err != nil { + return common.Recommendation{}, "", false, false, err } - if err := requireNonBlank("instance_class", args.InstanceClass); err != nil { - return err + instanceClass, err := requireNonBlank("instance_class", args.InstanceClass) + if err != nil { + return common.Recommendation{}, "", false, false, err } if args.Count <= 0 { - return fmt.Errorf("count must be > 0, got %d", args.Count) - } - if err := requireNonBlank("engine", args.Engine); err != nil { - return err + return common.Recommendation{}, "", false, false, fmt.Errorf("count must be > 0, got %d", args.Count) } - return nil -} - -// rdsRecommendationFromArgs validates args and builds the -// common.Recommendation to purchase, plus the effective dry_run/confirm -// booleans. -func rdsRecommendationFromArgs(args rdsRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if fieldErr := rdsRIPurchaseRequiredFields(args); fieldErr != nil { - return common.Recommendation{}, false, false, fieldErr + engine, err := requireNonBlank("engine", args.Engine) + if err != nil { + return common.Recommendation{}, "", false, false, err } term, err := ValidateTermYears(args.TermYears) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } paymentOption, err := ValidatePaymentOption(args.PaymentOption) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } azConfig, err := ValidateAZConfig(args.AZConfig) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } rec = common.Recommendation{ Provider: common.ProviderAWS, Service: common.ServiceRDS, - Region: args.Region, - ResourceType: args.InstanceClass, + Region: region, + ResourceType: instanceClass, Count: args.Count, CommitmentType: common.CommitmentReservedInstance, Term: term.RecommendationTerm(), PaymentOption: string(paymentOption), Details: &common.DatabaseDetails{ - Engine: args.Engine, + Engine: engine, AZConfig: string(azConfig), - InstanceClass: args.InstanceClass, + InstanceClass: instanceClass, }, } @@ -158,16 +154,21 @@ func rdsRecommendationFromArgs(args rdsRIPurchaseArgs) (rec common.Recommendatio if args.Confirm != nil { confirm = *args.Confirm } - return rec, dryRun, confirm, nil + return rec, region, dryRun, confirm, nil } -func (t *awsRDSRIPurchaseTool) resolveClient(args rdsRIPurchaseArgs) ResolveClientFunc { +// resolveClient returns the ResolveClientFunc that ExecutePurchase invokes +// only for a real purchase. region is the effective, already-validated-and- +// trimmed region returned by rdsRecommendationFromArgs -- not args.Region -- +// so a real purchase never resolves the provider/service client against a +// raw, un-trimmed value. +func (t *awsRDSRIPurchaseTool) resolveClient(args rdsRIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: args.Region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} prov, err := t.createProvider(string(common.ProviderAWS), cfg) if err != nil { return nil, err } - return prov.GetServiceClient(ctx, common.ServiceRDS, args.Region) + return prov.GetServiceClient(ctx, common.ServiceRDS, region) } } diff --git a/mcp/tools/aws_rds_ri_test.go b/mcp/tools/aws_rds_ri_test.go index a399a0c6f..664b69053 100644 --- a/mcp/tools/aws_rds_ri_test.go +++ b/mcp/tools/aws_rds_ri_test.go @@ -25,8 +25,9 @@ func validRDSArgs() rdsRIPurchaseArgs { func TestRDSRecommendationFromArgs(t *testing.T) { t.Parallel() - rec, dryRun, confirm, err := rdsRecommendationFromArgs(validRDSArgs()) + rec, region, dryRun, confirm, err := rdsRecommendationFromArgs(validRDSArgs()) require.NoError(t, err) + assert.Equal(t, "us-east-1", region) assert.True(t, dryRun) assert.False(t, confirm) assert.Equal(t, common.ServiceRDS, rec.Service) @@ -60,13 +61,37 @@ func TestRDSRecommendationFromArgsInvalid(t *testing.T) { t.Run(tc.name, func(t *testing.T) { args := validRDSArgs() tc.mutate(&args) - _, _, _, err := rdsRecommendationFromArgs(args) + _, _, _, _, err := rdsRecommendationFromArgs(args) require.Error(t, err) assert.Contains(t, err.Error(), tc.errSub) }) } } +// TestRDSRecommendationFromArgsTrimsSurroundingWhitespace is the regression +// guard for the CodeRabbit finding: requireNonBlank rejected an +// all-whitespace value but let a value with surrounding whitespace (e.g. +// " us-east-1 ") pass through unchanged into rec.Region/rec.ResourceType/ +// Details and the returned region (which resolveClient uses for +// ProviderConfig.Region and GetServiceClient). +func TestRDSRecommendationFromArgsTrimsSurroundingWhitespace(t *testing.T) { + t.Parallel() + args := validRDSArgs() + args.Region = " us-east-1 " + args.InstanceClass = " db.r6g.large " + args.Engine = " postgres " + + rec, region, _, _, err := rdsRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "us-east-1", region, "returned region must be trimmed") + assert.Equal(t, "us-east-1", rec.Region, "rec.Region must be trimmed") + assert.Equal(t, "db.r6g.large", rec.ResourceType, "rec.ResourceType must be trimmed") + details, ok := rec.Details.(*common.DatabaseDetails) + require.True(t, ok) + assert.Equal(t, "db.r6g.large", details.InstanceClass, "Details.InstanceClass must be trimmed") + assert.Equal(t, "postgres", details.Engine, "Details.Engine must be trimmed") +} + func TestAWSRDSRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { t.Parallel() resolveCalled := false diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index 77ee0f288..241baa166 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -134,17 +134,17 @@ func (t *simpleAWSRIPurchaseTool) Register(s *mcp.Server) error { } func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args simpleAWSRIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { - rec, dryRun, confirm, err := t.recommendationFromArgs(args) + rec, region, dryRun, confirm, err := t.recommendationFromArgs(args) if err != nil { return nil, PurchaseResponse{}, err } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: args.Region, + Region: region, Recommendation: rec, DryRun: dryRun, Confirm: confirm, - ResolveClient: t.resolveClient(args), + ResolveClient: t.resolveClient(args, region), Nonce: args.IdempotencyNonce, }) if err != nil { @@ -153,30 +153,35 @@ func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReq return nil, *resp, nil } -func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if fieldErr := requireNonBlank("region", args.Region); fieldErr != nil { - return common.Recommendation{}, false, false, fieldErr +// recommendationFromArgs validates args and builds the common.Recommendation +// to purchase, the effective region (trimmed of any surrounding whitespace), +// and the effective dry_run/confirm booleans. +func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchaseArgs) (rec common.Recommendation, region string, dryRun, confirm bool, err error) { + region, err = requireNonBlank("region", args.Region) + if err != nil { + return common.Recommendation{}, "", false, false, err } - if fieldErr := requireNonBlank("resource_type", args.ResourceType); fieldErr != nil { - return common.Recommendation{}, false, false, fieldErr + resourceType, err := requireNonBlank("resource_type", args.ResourceType) + if err != nil { + return common.Recommendation{}, "", false, false, err } if args.Count <= 0 { - return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) + return common.Recommendation{}, "", false, false, fmt.Errorf("count must be > 0, got %d", args.Count) } term, err := ValidateTermYears(args.TermYears) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } paymentOption, err := ValidatePaymentOption(args.PaymentOption) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } rec = common.Recommendation{ Provider: common.ProviderAWS, Service: t.spec.service, - Region: args.Region, - ResourceType: args.ResourceType, + Region: region, + ResourceType: resourceType, Count: args.Count, CommitmentType: common.CommitmentReservedInstance, Term: term.RecommendationTerm(), @@ -190,16 +195,21 @@ func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchas if args.Confirm != nil { confirm = *args.Confirm } - return rec, dryRun, confirm, nil + return rec, region, dryRun, confirm, nil } -func (t *simpleAWSRIPurchaseTool) resolveClient(args simpleAWSRIPurchaseArgs) ResolveClientFunc { +// resolveClient returns the ResolveClientFunc that ExecutePurchase invokes +// only for a real purchase. region is the effective, already-validated-and- +// trimmed region returned by recommendationFromArgs -- not args.Region -- +// so a real purchase never resolves the provider/service client against a +// raw, un-trimmed value. +func (t *simpleAWSRIPurchaseTool) resolveClient(args simpleAWSRIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: args.Region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} prov, err := t.createProvider(string(common.ProviderAWS), cfg) if err != nil { return nil, err } - return prov.GetServiceClient(ctx, t.spec.service, args.Region) + return prov.GetServiceClient(ctx, t.spec.service, region) } } diff --git a/mcp/tools/aws_simple_ri_test.go b/mcp/tools/aws_simple_ri_test.go index 0f19ce833..a48c8d9bc 100644 --- a/mcp/tools/aws_simple_ri_test.go +++ b/mcp/tools/aws_simple_ri_test.go @@ -75,8 +75,9 @@ func TestSimpleAWSRIPurchaseRecommendationFromArgs(t *testing.T) { for product, ctor := range simpleToolConstructors() { t.Run(product, func(t *testing.T) { tool := ctor().(*simpleAWSRIPurchaseTool) - rec, dryRun, confirm, err := tool.recommendationFromArgs(validSimpleArgs()) + rec, region, dryRun, confirm, err := tool.recommendationFromArgs(validSimpleArgs()) require.NoError(t, err) + assert.Equal(t, "us-east-1", region) assert.True(t, dryRun) assert.False(t, confirm) assert.Equal(t, common.ProviderAWS, rec.Provider) @@ -110,13 +111,33 @@ func TestSimpleAWSRIPurchaseInvalidArgs(t *testing.T) { t.Run(tc.name, func(t *testing.T) { args := validSimpleArgs() tc.mutate(&args) - _, _, _, err := tool.recommendationFromArgs(args) + _, _, _, _, err := tool.recommendationFromArgs(args) require.Error(t, err) assert.Contains(t, err.Error(), tc.errSub) }) } } +// TestSimpleAWSRIPurchaseRecommendationFromArgsTrimsSurroundingWhitespace is +// the regression guard for the CodeRabbit finding: requireNonBlank rejected +// an all-whitespace value but let a value with surrounding whitespace (e.g. +// " us-east-1 ") pass through unchanged into rec.Region/rec.ResourceType and +// the returned region (which resolveClient uses for ProviderConfig.Region +// and GetServiceClient). +func TestSimpleAWSRIPurchaseRecommendationFromArgsTrimsSurroundingWhitespace(t *testing.T) { + t.Parallel() + tool := NewAWSOpenSearchRIPurchaseTool().(*simpleAWSRIPurchaseTool) + args := validSimpleArgs() + args.Region = " us-east-1 " + args.ResourceType = " r6g.large " + + rec, region, _, _, err := tool.recommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "us-east-1", region, "returned region must be trimmed") + assert.Equal(t, "us-east-1", rec.Region, "rec.Region must be trimmed") + assert.Equal(t, "r6g.large", rec.ResourceType, "rec.ResourceType must be trimmed") +} + func TestSimpleAWSRIPurchaseHandleConfirmFalseRefuses(t *testing.T) { t.Parallel() for product, ctor := range simpleToolConstructors() { diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index 45a3429a3..6f31dfe5c 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -94,17 +94,17 @@ func (t *azureComputeRIPurchaseTool) Register(s *mcp.Server) error { } func (t *azureComputeRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args azureComputeRIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { - rec, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) + rec, region, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) if err != nil { return nil, PurchaseResponse{}, err } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: args.Region, + Region: region, Recommendation: rec, DryRun: dryRun, Confirm: confirm, - ResolveClient: t.resolveClient(args), + ResolveClient: t.resolveClient(args, region), Nonce: args.IdempotencyNonce, }) if err != nil { @@ -113,19 +113,21 @@ func (t *azureComputeRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTool return nil, *resp, nil } -func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if fieldErr := requireNonBlank("region", args.Region); fieldErr != nil { - return common.Recommendation{}, false, false, fieldErr +func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec common.Recommendation, region string, dryRun, confirm bool, err error) { + region, err = requireNonBlank("region", args.Region) + if err != nil { + return common.Recommendation{}, "", false, false, err } - if fieldErr := requireNonBlank("vm_size", args.VMSize); fieldErr != nil { - return common.Recommendation{}, false, false, fieldErr + vmSize, err := requireNonBlank("vm_size", args.VMSize) + if err != nil { + return common.Recommendation{}, "", false, false, err } if args.Count <= 0 { - return common.Recommendation{}, false, false, fmt.Errorf("count must be > 0, got %d", args.Count) + return common.Recommendation{}, "", false, false, fmt.Errorf("count must be > 0, got %d", args.Count) } term, err := ValidateTermYears(args.TermYears) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } // payment_option defaults to no-upfront (matching the CLI's --payment @@ -139,7 +141,7 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co } paymentOption, err := ValidatePaymentOption(paymentOptionStr) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } // Azure reservations support exactly two billing plans (Upfront, // Monthly -- see providers/azure/services/internal/reservations. @@ -148,7 +150,7 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co // for a real purchase), means a dry_run preview never reports success // for a request that could never be honored for real. if paymentOption == PaymentOptionPartialUpfront { - return common.Recommendation{}, false, false, fmt.Errorf( + return common.Recommendation{}, "", false, false, fmt.Errorf( "azure reservations do not support payment_option=%q: azure billing plans are all-upfront or "+ "no-upfront (monthly, same total price) only, with no partial-upfront option", paymentOption) @@ -165,24 +167,29 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co rec = common.Recommendation{ Provider: common.ProviderAzure, Service: common.ServiceCompute, - Region: args.Region, - ResourceType: args.VMSize, + Region: region, + ResourceType: vmSize, Count: args.Count, CommitmentType: common.CommitmentReservedInstance, Term: term.RecommendationTerm(), PaymentOption: string(paymentOption), } - return rec, dryRun, confirm, nil + return rec, region, dryRun, confirm, nil } -func (t *azureComputeRIPurchaseTool) resolveClient(args azureComputeRIPurchaseArgs) ResolveClientFunc { +// resolveClient returns the ResolveClientFunc that ExecutePurchase invokes +// only for a real purchase. region is the effective, already-validated-and- +// trimmed region returned by azureComputeRecommendationFromArgs -- not +// args.Region -- so a real purchase never resolves the provider/service +// client against a raw, un-trimmed value. +func (t *azureComputeRIPurchaseTool) resolveClient(args azureComputeRIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAzure), AzureSubscriptionID: args.AzureSubscriptionID, Region: args.Region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAzure), AzureSubscriptionID: args.AzureSubscriptionID, Region: region} prov, err := t.createProvider(string(common.ProviderAzure), cfg) if err != nil { return nil, err } - return prov.GetServiceClient(ctx, common.ServiceCompute, args.Region) + return prov.GetServiceClient(ctx, common.ServiceCompute, region) } } diff --git a/mcp/tools/azure_compute_ri_test.go b/mcp/tools/azure_compute_ri_test.go index 2a84c41b6..c5c6719fc 100644 --- a/mcp/tools/azure_compute_ri_test.go +++ b/mcp/tools/azure_compute_ri_test.go @@ -29,8 +29,9 @@ func validAzureComputeArgs() azureComputeRIPurchaseArgs { func TestAzureComputeRecommendationFromArgs(t *testing.T) { t.Parallel() - rec, dryRun, confirm, err := azureComputeRecommendationFromArgs(validAzureComputeArgs()) + rec, region, dryRun, confirm, err := azureComputeRecommendationFromArgs(validAzureComputeArgs()) require.NoError(t, err) + assert.Equal(t, "eastus", region) assert.True(t, dryRun) assert.False(t, confirm) assert.Equal(t, common.ProviderAzure, rec.Provider) @@ -41,6 +42,25 @@ func TestAzureComputeRecommendationFromArgs(t *testing.T) { assert.Nil(t, rec.Details, "Azure VM purchase reads no Recommendation.Details") } +// TestAzureComputeRecommendationFromArgsTrimsSurroundingWhitespace is the +// regression guard for the CodeRabbit finding: requireNonBlank rejected an +// all-whitespace value but let a value with surrounding whitespace (e.g. +// " eastus ") pass through unchanged into rec.Region/rec.ResourceType and the +// returned region (which resolveClient uses for ProviderConfig.Region and +// GetServiceClient). +func TestAzureComputeRecommendationFromArgsTrimsSurroundingWhitespace(t *testing.T) { + t.Parallel() + args := validAzureComputeArgs() + args.Region = " eastus " + args.VMSize = " Standard_D2s_v3 " + + rec, region, _, _, err := azureComputeRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "eastus", region, "returned region must be trimmed") + assert.Equal(t, "eastus", rec.Region, "rec.Region must be trimmed") + assert.Equal(t, "Standard_D2s_v3", rec.ResourceType, "rec.ResourceType must be trimmed") +} + func TestAzureComputeRecommendationFromArgsInvalid(t *testing.T) { t.Parallel() cases := []struct { @@ -60,7 +80,7 @@ func TestAzureComputeRecommendationFromArgsInvalid(t *testing.T) { t.Run(tc.name, func(t *testing.T) { args := validAzureComputeArgs() tc.mutate(&args) - _, _, _, err := azureComputeRecommendationFromArgs(args) + _, _, _, _, err := azureComputeRecommendationFromArgs(args) require.Error(t, err) assert.Contains(t, err.Error(), tc.errSub) }) @@ -84,7 +104,7 @@ func TestAzureComputeRecommendationFromArgsRejectsPartialUpfront(t *testing.T) { args.PaymentOption = "partial-upfront" args.DryRun = boolPtr(dryRun) args.Confirm = boolPtr(true) - _, _, _, err := azureComputeRecommendationFromArgs(args) + _, _, _, _, err := azureComputeRecommendationFromArgs(args) require.Error(t, err) assert.Contains(t, err.Error(), "partial-upfront") assert.Contains(t, err.Error(), "no-upfront") @@ -100,7 +120,7 @@ func TestAzureComputeRecommendationFromArgsAcceptsAllUpfront(t *testing.T) { args.PaymentOption = "all-upfront" args.DryRun = boolPtr(false) args.Confirm = boolPtr(true) - rec, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) + rec, _, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) require.NoError(t, err) assert.False(t, dryRun) assert.True(t, confirm) @@ -116,7 +136,7 @@ func TestAzureComputeRecommendationFromArgsAcceptsNoUpfront(t *testing.T) { args.PaymentOption = "no-upfront" args.DryRun = boolPtr(false) args.Confirm = boolPtr(true) - rec, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) + rec, _, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) require.NoError(t, err) assert.False(t, dryRun) assert.True(t, confirm) @@ -131,7 +151,7 @@ func TestAzureComputeRecommendationFromArgsDefaultsToNoUpfront(t *testing.T) { t.Parallel() args := validAzureComputeArgs() args.PaymentOption = "" - rec, _, _, err := azureComputeRecommendationFromArgs(args) + rec, _, _, _, err := azureComputeRecommendationFromArgs(args) require.NoError(t, err) assert.Equal(t, "no-upfront", rec.PaymentOption) } diff --git a/mcp/tools/enums.go b/mcp/tools/enums.go index 6db542602..11f09dbd6 100644 --- a/mcp/tools/enums.go +++ b/mcp/tools/enums.go @@ -10,17 +10,22 @@ import ( ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" ) -// requireNonBlank returns an explicit " is required" error when val is -// empty or contains only whitespace. A whitespace-only value (e.g. " ") -// passes a bare `== ""` check but carries no real region/instance-type/etc -// information, and on a real-purchase path could still reach provider -// resolution instead of being rejected at the MCP tool boundary like an -// actually-empty value already is. -func requireNonBlank(field, val string) error { - if strings.TrimSpace(val) == "" { - return fmt.Errorf("%s is required", field) +// requireNonBlank returns the trimmed form of val, or an explicit +// " is required" error when val is empty or contains only whitespace. +// A whitespace-only value (e.g. " ") passes a bare `== ""` check but +// carries no real region/instance-type/etc information, and on a +// real-purchase path could still reach provider resolution instead of being +// rejected at the MCP tool boundary like an actually-empty value already +// is. Callers must store the returned trimmed value (not the raw val) back +// onto the field so surrounding whitespace (e.g. " us-east-1 ") is +// normalized before it reaches a provider call rather than passed through +// raw. +func requireNonBlank(field, val string) (string, error) { + trimmed := strings.TrimSpace(val) + if trimmed == "" { + return "", fmt.Errorf("%s is required", field) } - return nil + return trimmed, nil } // PaymentOption is the AWS/Azure/GCP-agnostic reserved-capacity payment diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index c881bedce..abf78826c 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -78,17 +78,17 @@ func (t *gcpComputeEngineCUDPurchaseTool) Register(s *mcp.Server) error { } func (t *gcpComputeEngineCUDPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args gcpComputeEngineCUDPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { - rec, dryRun, confirm, err := gcpComputeEngineRecommendationFromArgs(args) + rec, region, dryRun, confirm, err := gcpComputeEngineRecommendationFromArgs(args) if err != nil { return nil, PurchaseResponse{}, err } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: args.Region, + Region: region, Recommendation: rec, DryRun: dryRun, Confirm: confirm, - ResolveClient: t.resolveClient(args), + ResolveClient: t.resolveClient(args, region), Nonce: args.IdempotencyNonce, }) if err != nil { @@ -97,46 +97,42 @@ func (t *gcpComputeEngineCUDPurchaseTool) handle(ctx context.Context, _ *mcp.Cal return nil, *resp, nil } -func gcpCUDPurchaseRequiredFields(args gcpComputeEngineCUDPurchaseArgs) error { - if err := requireNonBlank("region", args.Region); err != nil { - return err +// gcpComputeEngineRecommendationFromArgs validates args and builds the +// common.Recommendation to purchase, the effective region (trimmed of any +// surrounding whitespace), and the effective dry_run/confirm booleans. +// Details is set as a value (common.ComputeDetails{}), not a pointer, to +// match the value type assertion in +// providers/gcp/services/computeengine/client.go's memoryMBFromDetails. +func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs) (rec common.Recommendation, region string, dryRun, confirm bool, err error) { + region, err = requireNonBlank("region", args.Region) + if err != nil { + return common.Recommendation{}, "", false, false, err } - if err := requireNonBlank("machine_type", args.MachineType); err != nil { - return err + machineType, err := requireNonBlank("machine_type", args.MachineType) + if err != nil { + return common.Recommendation{}, "", false, false, err } if args.VCPUCount <= 0 { - return fmt.Errorf("vcpu_count must be > 0, got %d", args.VCPUCount) + return common.Recommendation{}, "", false, false, fmt.Errorf("vcpu_count must be > 0, got %d", args.VCPUCount) } if args.MemoryGB <= 0 { - return fmt.Errorf("memory_gb must be > 0, got %v", args.MemoryGB) - } - return nil -} - -// gcpComputeEngineRecommendationFromArgs validates args and builds the -// common.Recommendation to purchase, plus the effective dry_run/confirm -// booleans. Details is set as a value (common.ComputeDetails{}), not a -// pointer, to match the value type assertion in -// providers/gcp/services/computeengine/client.go's memoryMBFromDetails. -func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs) (rec common.Recommendation, dryRun, confirm bool, err error) { - if fieldErr := gcpCUDPurchaseRequiredFields(args); fieldErr != nil { - return common.Recommendation{}, false, false, fieldErr + return common.Recommendation{}, "", false, false, fmt.Errorf("memory_gb must be > 0, got %v", args.MemoryGB) } term, err := ValidateTermYears(args.TermYears) if err != nil { - return common.Recommendation{}, false, false, err + return common.Recommendation{}, "", false, false, err } rec = common.Recommendation{ Provider: common.ProviderGCP, Service: common.ServiceCompute, - Region: args.Region, - ResourceType: args.MachineType, + Region: region, + ResourceType: machineType, Count: args.VCPUCount, CommitmentType: common.CommitmentCUD, Term: term.RecommendationTerm(), Details: common.ComputeDetails{ - InstanceType: args.MachineType, + InstanceType: machineType, MemoryGB: args.MemoryGB, }, } @@ -148,16 +144,21 @@ func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs if args.Confirm != nil { confirm = *args.Confirm } - return rec, dryRun, confirm, nil + return rec, region, dryRun, confirm, nil } -func (t *gcpComputeEngineCUDPurchaseTool) resolveClient(args gcpComputeEngineCUDPurchaseArgs) ResolveClientFunc { +// resolveClient returns the ResolveClientFunc that ExecutePurchase invokes +// only for a real purchase. region is the effective, already-validated-and- +// trimmed region returned by gcpComputeEngineRecommendationFromArgs -- not +// args.Region -- so a real purchase never resolves the provider/service +// client against a raw, un-trimmed value. +func (t *gcpComputeEngineCUDPurchaseTool) resolveClient(args gcpComputeEngineCUDPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderGCP), GCPProjectID: args.GCPProjectID, Region: args.Region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderGCP), GCPProjectID: args.GCPProjectID, Region: region} prov, err := t.createProvider(string(common.ProviderGCP), cfg) if err != nil { return nil, err } - return prov.GetServiceClient(ctx, common.ServiceCompute, args.Region) + return prov.GetServiceClient(ctx, common.ServiceCompute, region) } } diff --git a/mcp/tools/gcp_computeengine_cud_test.go b/mcp/tools/gcp_computeengine_cud_test.go index 18ebb5d69..9234ce0e5 100644 --- a/mcp/tools/gcp_computeengine_cud_test.go +++ b/mcp/tools/gcp_computeengine_cud_test.go @@ -23,8 +23,9 @@ func validGCPCUDArgs() gcpComputeEngineCUDPurchaseArgs { func TestGCPComputeEngineRecommendationFromArgs(t *testing.T) { t.Parallel() - rec, dryRun, confirm, err := gcpComputeEngineRecommendationFromArgs(validGCPCUDArgs()) + rec, region, dryRun, confirm, err := gcpComputeEngineRecommendationFromArgs(validGCPCUDArgs()) require.NoError(t, err) + assert.Equal(t, "us-central1", region) assert.True(t, dryRun) assert.False(t, confirm) assert.Equal(t, common.ProviderGCP, rec.Provider) @@ -63,13 +64,35 @@ func TestGCPComputeEngineRecommendationFromArgsInvalid(t *testing.T) { t.Run(tc.name, func(t *testing.T) { args := validGCPCUDArgs() tc.mutate(&args) - _, _, _, err := gcpComputeEngineRecommendationFromArgs(args) + _, _, _, _, err := gcpComputeEngineRecommendationFromArgs(args) require.Error(t, err) assert.Contains(t, err.Error(), tc.errSub) }) } } +// TestGCPComputeEngineRecommendationFromArgsTrimsSurroundingWhitespace is the +// regression guard for the CodeRabbit finding: requireNonBlank rejected an +// all-whitespace value but let a value with surrounding whitespace (e.g. +// " us-central1 ") pass through unchanged into rec.Region/rec.ResourceType and +// the returned region (which resolveClient uses for ProviderConfig.Region and +// GetServiceClient). +func TestGCPComputeEngineRecommendationFromArgsTrimsSurroundingWhitespace(t *testing.T) { + t.Parallel() + args := validGCPCUDArgs() + args.Region = " us-central1 " + args.MachineType = " n2-standard-4 " + + rec, region, _, _, err := gcpComputeEngineRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "us-central1", region, "returned region must be trimmed") + assert.Equal(t, "us-central1", rec.Region, "rec.Region must be trimmed") + assert.Equal(t, "n2-standard-4", rec.ResourceType, "rec.ResourceType must be trimmed") + details, ok := rec.Details.(common.ComputeDetails) + require.True(t, ok) + assert.Equal(t, "n2-standard-4", details.InstanceType, "Details.InstanceType must be trimmed") +} + func TestGCPComputeEngineCUDPurchaseHandleConfirmFalseRefuses(t *testing.T) { t.Parallel() resolveCalled := false diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 3edc398c3..282fd5b48 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -397,13 +397,13 @@ func TestIdempotencyKeyDistinguishesEC2Platform(t *testing.T) { windowsArgs := validEC2Args() windowsArgs.Platform = "Windows" - linuxRec, _, _, err := ec2RecommendationFromArgs(linuxArgs) + linuxRec, linuxRegion, _, _, err := ec2RecommendationFromArgs(linuxArgs) require.NoError(t, err) - windowsRec, _, _, err := ec2RecommendationFromArgs(windowsArgs) + windowsRec, windowsRegion, _, _, err := ec2RecommendationFromArgs(windowsArgs) require.NoError(t, err) - linuxKey := idempotencyKeyFor(linuxArgs.Region, linuxRec, "") - windowsKey := idempotencyKeyFor(windowsArgs.Region, windowsRec, "") + linuxKey := idempotencyKeyFor(linuxRegion, linuxRec, "") + windowsKey := idempotencyKeyFor(windowsRegion, windowsRec, "") assert.NotEqual(t, linuxKey, windowsKey, "a Linux and a Windows EC2 RI purchase must not derive the same idempotency key") } From 34d802476609f3b555fcf758ffad8e57d9b4444f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 21:41:38 +0200 Subject: [PATCH 41/83] fix(mcp): validate lookback_period in the search_recommendations handler lookback_period was constrained only by the tool's advertised jsonschema enum, not re-validated in the handler, so a direct MCP call bypassing schema enforcement could pass an unsupported value through to the provider. Add the typed LookbackPeriod enum and validate against it in validateSearchArgs, consistent with how payment_option and the other enumerable fields are already re-validated at the handler boundary. --- mcp/tools/enums.go | 28 ++++++++++++++++++++++++ mcp/tools/search_recommendations.go | 10 ++++++--- mcp/tools/search_recommendations_test.go | 19 ++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/mcp/tools/enums.go b/mcp/tools/enums.go index 11f09dbd6..7b8157e2f 100644 --- a/mcp/tools/enums.go +++ b/mcp/tools/enums.go @@ -193,6 +193,34 @@ func ValidateScope(s string) (Scope, error) { } } +// LookbackPeriod is the cost/usage lookback window backing a +// cudly_search_recommendations call. Values match the enum +// search_recommendations.go's Register advertises in the tool's JSON schema +// (BuildInputSchema's "lookback_period" FieldOverride); re-validated here so +// a caller invoking the tool directly -- bypassing MCP schema enforcement -- +// cannot pass an unsupported window through to the provider. +type LookbackPeriod string + +const ( + LookbackPeriod7Days LookbackPeriod = "7d" + LookbackPeriod30Days LookbackPeriod = "30d" + LookbackPeriod60Days LookbackPeriod = "60d" +) + +// ValidateLookbackPeriod returns the typed LookbackPeriod for s, or an +// explicit error when s is non-empty and not one of the three supported +// windows. An empty s is valid: lookback_period is optional +// (json:"...,omitempty"), meaning "let the provider apply its own default". +func ValidateLookbackPeriod(s string) (LookbackPeriod, error) { + switch LookbackPeriod(s) { + case "", LookbackPeriod7Days, LookbackPeriod30Days, LookbackPeriod60Days: + return LookbackPeriod(s), nil + default: + return "", fmt.Errorf("invalid lookback_period %q: must be one of %s, %s, %s", + s, LookbackPeriod7Days, LookbackPeriod30Days, LookbackPeriod60Days) + } +} + // CacheEngine is the ElastiCache engine dimension (common.CacheDetails.Engine). type CacheEngine string diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index 0c8566aca..82b5f54a7 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -116,9 +116,9 @@ func (t *searchRecommendationsTool) handle(ctx context.Context, _ *mcp.CallToolR // validateSearchArgs validates every money-neutral-but-still-typed field on // args that does not require a live provider (provider name, payment -// option, term, Savings Plans type filters), returning the typed provider -// name and the normalised Recommendation term string ("1yr"/"3yr", or "" -// when args.TermYears was omitted). +// option, lookback_period, term, Savings Plans type filters), returning the +// typed provider name and the normalised Recommendation term string +// ("1yr"/"3yr", or "" when args.TermYears was omitted). func validateSearchArgs(args searchRecommendationsArgs) (common.ProviderType, string, error) { providerType, err := validateProviderName(args.Provider) if err != nil { @@ -131,6 +131,10 @@ func validateSearchArgs(args searchRecommendationsArgs) (common.ProviderType, st } } + if _, err := ValidateLookbackPeriod(args.LookbackPeriod); err != nil { + return "", "", err + } + term := "" if args.TermYears != 0 { ty, err := ValidateTermYears(args.TermYears) diff --git a/mcp/tools/search_recommendations_test.go b/mcp/tools/search_recommendations_test.go index ff8fccac0..74325309e 100644 --- a/mcp/tools/search_recommendations_test.go +++ b/mcp/tools/search_recommendations_test.go @@ -159,6 +159,25 @@ func TestSearchRecommendationsInvalidTermYears(t *testing.T) { assert.Contains(t, err.Error(), "invalid term_years") } +// TestSearchRecommendationsInvalidLookbackPeriod is the regression guard for +// the CodeRabbit finding: lookback_period was constrained only by the +// advertised MCP jsonschema enum (Register's BuildInputSchema override), not +// re-validated in the handler, so a direct MCP call bypassing schema +// enforcement could pass an unsupported value through to the provider. +func TestSearchRecommendationsInvalidLookbackPeriod(t *testing.T) { + t.Parallel() + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + LookbackPeriod: "90d", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid lookback_period") +} + func TestSearchRecommendationsInvalidSPType(t *testing.T) { t.Parallel() fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceSavingsPlansAll}} From 688dac767454606f9fc5a8ff0571519db2c72aea Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 22:03:13 +0200 Subject: [PATCH 42/83] fix(azure): validate term before capacity provider registration buildReservationBody parses rec.Term via ParseTermYears after ensureCapacityProviderRegistered already ran, so an invalid term still triggered the Microsoft.Capacity provider-registration side effect before the purchase was rejected. Move body construction (which validates both payment option and term) ahead of registration in PurchaseCommitment. --- providers/azure/services/compute/client.go | 16 +++++++------ .../azure/services/compute/client_test.go | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/providers/azure/services/compute/client.go b/providers/azure/services/compute/client.go index 011873e49..6e1ded048 100644 --- a/providers/azure/services/compute/client.go +++ b/providers/azure/services/compute/client.go @@ -482,25 +482,27 @@ func (c *ComputeClient) PurchaseCommitment(ctx context.Context, rec common.Recom return result, result.Error } - // Validate the payment option BEFORE any side-effecting call. Microsoft.Capacity - // provider registration below is a real ARM operation (a GET, and a POST to - // register when unregistered); an invalid/empty PaymentOption must be rejected - // here first so a doomed purchase never triggers it. + // Validate the payment option and build the request body BEFORE any + // side-effecting call. Microsoft.Capacity provider registration below is + // a real ARM operation (a GET, and a POST to register when unregistered); + // every fallible local parse -- payment option AND term (buildReservationBody + // calls reservations.ParseTermYears) -- must be rejected here first so a + // doomed purchase never triggers it. billingPlan, err := reservations.BillingPlanForPaymentOption(rec.PaymentOption) if err != nil { result.Error = err return result, result.Error } - // Ensure Microsoft.Capacity provider is registered (cached after first call). - c.ensureCapacityProviderRegistered(ctx) - bodyBytes, err := c.buildReservationBody(rec, billingPlan, opts.Source, opts.IdempotencyToken) if err != nil { result.Error = fmt.Errorf("failed to marshal request: %w", err) return result, result.Error } + // Ensure Microsoft.Capacity provider is registered (cached after first call). + c.ensureCapacityProviderRegistered(ctx) + token, err := c.cred.GetToken(ctx, policy.TokenRequestOptions{ Scopes: []string{"https://management.azure.com/.default"}, }) diff --git a/providers/azure/services/compute/client_test.go b/providers/azure/services/compute/client_test.go index 1e406eaed..8c551fd29 100644 --- a/providers/azure/services/compute/client_test.go +++ b/providers/azure/services/compute/client_test.go @@ -947,6 +947,29 @@ func TestComputeClient_PurchaseCommitment_RejectsInvalidPaymentOptionBeforeSideE mockHTTP.AssertNotCalled(t, "Do", mock.Anything) } +// TestComputeClient_PurchaseCommitment_RejectsInvalidTermBeforeSideEffects +// pins the same guarantee as the invalid-payment-option test above but for +// rec.Term: buildReservationBody parses the term via +// reservations.ParseTermYears, and that parse must be rejected before +// ensureCapacityProviderRegistered's real ARM GET/POST ever fires. Regression +// test for a prior fix that reordered PaymentOption validation ahead of +// registration but left Term parsing (inside buildReservationBody) running +// after it, so an invalid term still triggered the provider-registration +// side effect before the purchase was rejected. +func TestComputeClient_PurchaseCommitment_RejectsInvalidTermBeforeSideEffects(t *testing.T) { + ctx := context.Background() + mockHTTP := &mocks.MockHTTPClient{} + mockCred := &MockTokenCredential{token: "test-token"} + client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) + + rec := common.Recommendation{ResourceType: "Standard_D2s_v3", Term: "5yr", Count: 1, PaymentOption: "no-upfront"} + result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), "unsupported reservation term") + mockHTTP.AssertNotCalled(t, "Do", mock.Anything) +} + // TestComputeClient_ConvertAzureVMRecommendation_NilGuards pins the new // contract: unusable SDK payloads (nil, wrong concrete type, nil Properties) // produce a nil *Recommendation so the caller can filter it out. Before From c9c30d72a1f72daab91928913b0f9536c43a62bd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 22:21:17 +0200 Subject: [PATCH 43/83] fix(mcp): trim region/instance_family before storing on SP recommendation validateSavingsPlanArgs only trimmed args.Region and args.InstanceFamily for the blank-check; savingsPlanRecommendationFromArgs then stored the raw, untrimmed values into the resolved region, rec.Region, and Details.Region/InstanceFamily. Those flow into ProviderConfig, GetServiceClient, and the DescribeSavingsPlansOfferings lookup for a real EC2Instance Savings Plan purchase, so " us-east-1 " passed validation but could reach a real purchase with surrounding whitespace intact. --- mcp/tools/aws_savingsplans.go | 17 +++++++-- mcp/tools/aws_savingsplans_test.go | 61 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index ed4001199..dbd09fd81 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -161,8 +161,17 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo return common.Recommendation{}, "", false, false, err } - region = args.Region - if strings.TrimSpace(region) == "" { + // Trim once and use the trimmed value everywhere region/instance_family + // are stored or forwarded (resolved region, rec.Region, Details.Region, + // Details.InstanceFamily): validateSavingsPlanArgs above only trims for + // the blank-check, so a caller-supplied " us-east-1 " would otherwise + // flow raw into ProviderConfig/GetServiceClient and the real + // DescribeSavingsPlansOfferings lookup for an EC2Instance purchase. + trimmedRegion := strings.TrimSpace(args.Region) + trimmedInstanceFamily := strings.TrimSpace(args.InstanceFamily) + + region = trimmedRegion + if region == "" { region = savingsPlansAccountLevelRegion } @@ -185,8 +194,8 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo // that contract instead of leaking a caller-supplied region/family into // an account-level, family-agnostic plan's Details. if spType == SPTypeEC2Instance { - details.InstanceFamily = args.InstanceFamily - details.Region = args.Region + details.InstanceFamily = trimmedInstanceFamily + details.Region = trimmedRegion } rec = common.Recommendation{ diff --git a/mcp/tools/aws_savingsplans_test.go b/mcp/tools/aws_savingsplans_test.go index 49d37fec3..ed1c05d75 100644 --- a/mcp/tools/aws_savingsplans_test.go +++ b/mcp/tools/aws_savingsplans_test.go @@ -216,6 +216,67 @@ func TestSavingsPlanRecommendationFromArgsDetailsRegionFamilyOnlyForEC2Instance( }) } +// TestSavingsPlanRecommendationFromArgsTrimsSurroundingWhitespace is the +// regression guard for the CodeRabbit money-path finding: +// validateSavingsPlanArgs only trims region/instance_family for the +// blank-check (its `strings.TrimSpace(args.Region) == ""` guards), but +// savingsPlanRecommendationFromArgs then stored the RAW args.Region and +// args.InstanceFamily into the resolved region, rec.Region, and +// Details.Region/Details.InstanceFamily -- which flow into +// ProviderConfig/GetServiceClient and the DescribeSavingsPlansOfferings +// lookup for a real EC2Instance Savings Plan purchase. Before the fix, +// " us-east-1 " passed validation but reached a real purchase with the +// surrounding whitespace intact. +func TestSavingsPlanRecommendationFromArgsTrimsSurroundingWhitespace(t *testing.T) { + t.Parallel() + args := validSavingsPlansArgs() + args.SPType = "EC2Instance" + args.Region = " us-east-1 " + args.InstanceFamily = " m5 " + + rec, region, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "us-east-1", region, "returned region must be trimmed") + assert.Equal(t, "us-east-1", rec.Region, "rec.Region must be trimmed") + details, ok := rec.Details.(*common.SavingsPlanDetails) + require.True(t, ok) + assert.Equal(t, "us-east-1", details.Region, "Details.Region must be trimmed") + assert.Equal(t, "m5", details.InstanceFamily, "Details.InstanceFamily must be trimmed") +} + +// TestAWSSavingsPlansPurchaseHandleForwardsTrimmedRegionToServiceClient +// proves the trimmed region -- not the raw, whitespace-padded args.Region -- +// is what actually reaches GetServiceClient on a real EC2Instance Savings +// Plan purchase, i.e. the fix holds through the full handle() path, not just +// the recommendation-building helper. +func TestAWSSavingsPlansPurchaseHandleForwardsTrimmedRegionToServiceClient(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "sp-2"}} + var gotService common.ServiceType + var gotRegion string + tool := &awsSavingsPlansPurchaseTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "aws"}, + client: fake, + gotService: &gotService, + gotRegion: &gotRegion, + }, nil + }, + } + args := validSavingsPlansArgs() + args.SPType = "EC2Instance" + args.Region = " us-east-1 " + args.InstanceFamily = " m5 " + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, "us-east-1", gotRegion, "GetServiceClient must receive the trimmed region, not raw whitespace") +} + func TestSavingsPlanRecommendationFromArgsInvalid(t *testing.T) { t.Parallel() cases := []struct { From 083f9c34dadb2b7b4d3c2438ec4fd72fc0989ae1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 22:21:38 +0200 Subject: [PATCH 44/83] fix(mcp): trim region/account identifiers in search_recommendations region, include_regions, exclude_regions, and account_filter were passed straight through to ProviderConfig.Region and RecommendationParams with no trim, unlike the purchase tools' requireNonBlank. A caller-supplied " us-east-1 " could resolve the wrong (or account-default) region and silently miss matching recommendations. region is optional here, so trim rather than reject surrounding whitespace. --- mcp/tools/search_recommendations.go | 30 ++++++++++++++++++ mcp/tools/search_recommendations_test.go | 39 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index 82b5f54a7..e5398cc12 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -90,6 +90,7 @@ func (t *searchRecommendationsTool) handle(ctx context.Context, _ *mcp.CallToolR if err != nil { return nil, searchRecommendationsResult{}, err } + args = trimSearchArgsIdentifiers(args) prov, err := t.createProvider(string(providerType), providerConfigFromArgs(providerType, args)) if err != nil { @@ -168,6 +169,35 @@ func validateSPTypeFilters(include, exclude []string) error { return nil } +// trimSearchArgsIdentifiers returns args with surrounding whitespace +// stripped from every free-text identifier field that flows into +// providerConfigFromArgs/recommendationParamsFromArgs (region, +// include_regions, exclude_regions, account_filter). Unlike the purchase +// tools' requireNonBlank, region is optional here, so trimming rather than +// rejecting a blank/whitespace value is correct: " us-east-1 " must resolve +// and search the same region as "us-east-1" instead of silently searching +// the wrong (or account-default) region. +func trimSearchArgsIdentifiers(args searchRecommendationsArgs) searchRecommendationsArgs { + args.Region = strings.TrimSpace(args.Region) + args.IncludeRegions = trimAll(args.IncludeRegions) + args.ExcludeRegions = trimAll(args.ExcludeRegions) + args.AccountFilter = trimAll(args.AccountFilter) + return args +} + +// trimAll returns a copy of ss with strings.TrimSpace applied to every +// entry, preserving a nil input as nil. +func trimAll(ss []string) []string { + if ss == nil { + return nil + } + out := make([]string, len(ss)) + for i, s := range ss { + out[i] = strings.TrimSpace(s) + } + return out +} + // providerConfigFromArgs builds the provider.ProviderConfig for the given // provider from the tool's per-call credential override fields (design §4). func providerConfigFromArgs(providerType common.ProviderType, args searchRecommendationsArgs) *provider.ProviderConfig { diff --git a/mcp/tools/search_recommendations_test.go b/mcp/tools/search_recommendations_test.go index 74325309e..31bf161f4 100644 --- a/mcp/tools/search_recommendations_test.go +++ b/mcp/tools/search_recommendations_test.go @@ -121,6 +121,45 @@ func TestSearchRecommendationsForwardsRegionFilters(t *testing.T) { assert.Equal(t, []string{"eu-west-1"}, client.lastParams.ExcludeRegions) } +// TestSearchRecommendationsTrimsRegionFilters is the regression guard for +// the CodeRabbit finding: region (and include_regions/exclude_regions/ +// account_filter) were passed straight through to ProviderConfig.Region and +// RecommendationParams with no trim, unlike the purchase tools' +// requireNonBlank. " us-east-1 " must forward as "us-east-1" to both the +// provider config used to resolve the client and the params sent to the +// recommendations client -- region is optional here, so trimming (not +// rejecting) surrounding whitespace is the correct behavior. +func TestSearchRecommendationsTrimsRegionFilters(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + var gotCfg *provider.ProviderConfig + tool := &searchRecommendationsTool{ + createProvider: func(_ string, cfg *provider.ProviderConfig) (provider.Provider, error) { + gotCfg = cfg + return fp, nil + }, + } + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + Region: " us-east-1 ", + IncludeRegions: []string{" us-east-1 ", " us-west-2 "}, + ExcludeRegions: []string{" eu-west-1 "}, + AccountFilter: []string{" 123456789012 "}, + }) + + require.NoError(t, err) + require.NotNil(t, gotCfg) + assert.Equal(t, "us-east-1", gotCfg.Region, "ProviderConfig.Region must be trimmed") + require.NotNil(t, client.lastParams) + assert.Equal(t, "us-east-1", client.lastParams.Region, "RecommendationParams.Region must be trimmed") + assert.Equal(t, []string{"us-east-1", "us-west-2"}, client.lastParams.IncludeRegions, "IncludeRegions entries must be trimmed") + assert.Equal(t, []string{"eu-west-1"}, client.lastParams.ExcludeRegions, "ExcludeRegions entries must be trimmed") + assert.Equal(t, []string{"123456789012"}, client.lastParams.AccountFilter, "AccountFilter entries must be trimmed") +} + func TestSearchRecommendationsInvalidProvider(t *testing.T) { t.Parallel() tool := newTestSearchTool(&fakeProvider{}) From 911ea3aa77b660b4ccb667c96ffe6e0f929c1a1a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:50:03 +0200 Subject: [PATCH 45/83] fix(azure): wire billingPlan into SQL Database reservation purchases Azure SQL Database reserved capacity supports both Upfront and Monthly billing (learn.microsoft.com/azure/azure-sql/database/reservations-discount-overview). buildReservationBody never set properties.billingPlan, so a no-upfront recommendation was silently billed at Azure's Upfront default. Map rec.PaymentOption via the shared reservations.BillingPlanForPaymentOption helper (added in #1495 for compute) and fail loud on empty/unrecognized values instead of defaulting. Part of #1502. --- providers/azure/services/database/client.go | 6 ++ .../azure/services/database/client_test.go | 92 +++++++++++++++++-- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/providers/azure/services/database/client.go b/providers/azure/services/database/client.go index 88614306c..dd2a611d6 100644 --- a/providers/azure/services/database/client.go +++ b/providers/azure/services/database/client.go @@ -327,6 +327,11 @@ func (c *DatabaseClient) PurchaseCommitment(ctx context.Context, rec common.Reco result.Error = termErr return result, result.Error } + billingPlan, billingPlanErr := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + if billingPlanErr != nil { + result.Error = billingPlanErr + return result, result.Error + } requestBody := map[string]interface{}{ "sku": map[string]string{ @@ -336,6 +341,7 @@ func (c *DatabaseClient) PurchaseCommitment(ctx context.Context, rec common.Reco "properties": map[string]interface{}{ "reservedResourceType": string(armreservations.ReservedResourceTypeSQLDatabases), "billingScopeId": fmt.Sprintf("/subscriptions/%s", c.subscriptionID), + "billingPlan": string(billingPlan), "term": fmt.Sprintf("P%dY", termYears), "quantity": rec.Count, "displayName": reservations.BuildDisplayName(reservations.DisplayNameFields{ diff --git a/providers/azure/services/database/client_test.go b/providers/azure/services/database/client_test.go index 7b0d84173..88925cfb2 100644 --- a/providers/azure/services/database/client_test.go +++ b/providers/azure/services/database/client_test.go @@ -901,6 +901,7 @@ func TestDatabaseClient_PurchaseCommitment_Success(t *testing.T) { ResourceType: "GP_Gen5_8", Term: "1yr", Count: 1, + PaymentOption: "no-upfront", CommitmentCost: 5000.0, } @@ -929,6 +930,7 @@ func TestDatabaseClient_PurchaseCommitment_3YearTerm(t *testing.T) { ResourceType: "GP_Gen5_8", Term: "3yr", Count: 1, + PaymentOption: "no-upfront", CommitmentCost: 12000.0, } @@ -956,6 +958,7 @@ func TestDatabaseClient_PurchaseCommitment_Accepted(t *testing.T) { ResourceType: "GP_Gen5_8", Term: "1yr", Count: 1, + PaymentOption: "no-upfront", CommitmentCost: 5000.0, } @@ -972,9 +975,10 @@ func TestDatabaseClient_PurchaseCommitment_TokenError(t *testing.T) { client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) rec := common.Recommendation{ - ResourceType: "GP_Gen5_8", - Term: "1yr", - Count: 1, + ResourceType: "GP_Gen5_8", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -994,9 +998,10 @@ func TestDatabaseClient_PurchaseCommitment_HTTPError(t *testing.T) { })).Return(nil, errors.New("network error")).Once() rec := common.Recommendation{ - ResourceType: "GP_Gen5_8", - Term: "1yr", - Count: 1, + ResourceType: "GP_Gen5_8", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -1019,9 +1024,10 @@ func TestDatabaseClient_PurchaseCommitment_BadStatus(t *testing.T) { })).Return(createMockHTTPResponse(http.StatusBadRequest, `{"error": "invalid request"}`), nil).Once() rec := common.Recommendation{ - ResourceType: "GP_Gen5_8", - Term: "1yr", - Count: 1, + ResourceType: "GP_Gen5_8", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -1059,7 +1065,7 @@ func TestDatabaseClient_PurchaseCommitment_TagInjection(t *testing.T) { return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" })).Return(createMockHTTPResponse(http.StatusOK, `{}`), nil).Once() - rec := common.Recommendation{ResourceType: "GP_Gen5_8", Term: "1yr", Count: 1, CommitmentCost: 5000.0} + rec := common.Recommendation{ResourceType: "GP_Gen5_8", Term: "1yr", Count: 1, CommitmentCost: 5000.0, PaymentOption: "no-upfront"} result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: source}) require.NoError(t, err) assert.True(t, result.Success) @@ -1072,6 +1078,70 @@ func TestDatabaseClient_PurchaseCommitment_TagInjection(t *testing.T) { mockHTTP.AssertExpectations(t) } +// TestDatabaseClient_PurchaseCommitment_BillingPlan pins the billingPlan +// wiring (issue #1502, mirroring PR #1495's fix for compute): Azure SQL +// Database reserved capacity supports both Upfront and Monthly billing +// (learn.microsoft.com/azure/azure-sql/database/reservations-discount-overview), +// so rec.PaymentOption must map onto the correct armreservations +// ReservationBillingPlan value in the purchase body rather than silently +// defaulting to Azure's Upfront behavior for a no-upfront/monthly rec. +func TestDatabaseClient_PurchaseCommitment_BillingPlan(t *testing.T) { + cases := []struct { + name string + paymentOption string + wantPlan string + wantErrSub string + }{ + {name: "all-upfront maps to Upfront", paymentOption: "all-upfront", wantPlan: "Upfront"}, + {name: "no-upfront maps to Monthly", paymentOption: "no-upfront", wantPlan: "Monthly"}, + {name: "partial-upfront is rejected", paymentOption: "partial-upfront", wantErrSub: "partial-upfront has no azure equivalent"}, + {name: "empty payment option is rejected", paymentOption: "", wantErrSub: "azure reservations support only upfront or monthly billing"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mockHTTP := &MockHTTPClient{} + mockCred := &MockTokenCredential{token: "test-token"} + client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) + + rec := common.Recommendation{ResourceType: "GP_Gen5_8", Term: "1yr", Count: 1, PaymentOption: tc.paymentOption} + + if tc.wantErrSub != "" { + result, err := client.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), tc.wantErrSub) + mockHTTP.AssertNotCalled(t, "Do", mock.Anything) + return + } + + const orderID = "db-billingplan-test" + var capturedBody []byte + mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool { + if r.URL.Path != "/providers/Microsoft.Capacity/calculatePrice" { + return false + } + capturedBody, _ = io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(capturedBody)) + return true + })).Return(createMockHTTPResponse(http.StatusOK, calcPriceRespJSON(orderID)), nil).Once() + mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool { + return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" + })).Return(createMockHTTPResponse(http.StatusOK, `{}`), nil).Once() + + result, err := client.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.NoError(t, err) + assert.True(t, result.Success) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(capturedBody, &body)) + props, ok := body["properties"].(map[string]interface{}) + require.True(t, ok, "properties map missing from reservation body") + assert.Equal(t, tc.wantPlan, props["billingPlan"]) + mockHTTP.AssertExpectations(t) + }) + } +} + // TestDatabaseClient_PurchaseCommitment_RequiresSource pins the dedupe guard: // PurchaseCommitment must reject an empty opts.Source before issuing any HTTP // call. Azure mints the reservation order ID server-side, so the @@ -1237,6 +1307,7 @@ func TestDatabaseClient_PurchaseCommitment_DisplayNameConformsToAzureAllowlist(t ResourceType: "GP_Gen5_2", Term: "1yr", Count: 1, + PaymentOption: "no-upfront", CommitmentCost: 1500.0, } _, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -1289,6 +1360,7 @@ func TestDatabaseClient_PurchaseCommitment_CanonicalReservedResourceType(t *test ResourceType: "GP_Gen5_2", Term: "1yr", Count: 1, + PaymentOption: "no-upfront", CommitmentCost: 1500.0, } _, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) From 682da45509848f94ba8b52d71855960849791298 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:50:39 +0200 Subject: [PATCH 46/83] fix(azure): wire billingPlan into Cache for Redis reservation purchases Azure Cache for Redis reserved capacity supports both Upfront and Monthly billing (learn.microsoft.com/azure/azure-cache-for-redis/cache-reserved-pricing). PurchaseCommitment never set properties.billingPlan, so a no-upfront recommendation was silently billed at Azure's Upfront default. Map rec.PaymentOption via the shared reservations.BillingPlanForPaymentOption helper (added in #1495 for compute) and fail loud on empty/unrecognized values instead of defaulting. Part of #1502. --- providers/azure/services/cache/client.go | 6 ++ providers/azure/services/cache/client_test.go | 92 +++++++++++++++++-- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/providers/azure/services/cache/client.go b/providers/azure/services/cache/client.go index 753ae2e82..0b2f031c4 100644 --- a/providers/azure/services/cache/client.go +++ b/providers/azure/services/cache/client.go @@ -296,6 +296,11 @@ func (c *CacheClient) PurchaseCommitment(ctx context.Context, rec common.Recomme result.Error = termErr return result, result.Error } + billingPlan, billingPlanErr := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + if billingPlanErr != nil { + result.Error = billingPlanErr + return result, result.Error + } requestBody := map[string]interface{}{ "sku": map[string]string{ @@ -305,6 +310,7 @@ func (c *CacheClient) PurchaseCommitment(ctx context.Context, rec common.Recomme "properties": map[string]interface{}{ "reservedResourceType": string(armreservations.ReservedResourceTypeRedisCache), "billingScopeId": fmt.Sprintf("/subscriptions/%s", c.subscriptionID), + "billingPlan": string(billingPlan), "term": fmt.Sprintf("P%dY", termYears), "quantity": rec.Count, "displayName": reservations.BuildDisplayName(reservations.DisplayNameFields{ diff --git a/providers/azure/services/cache/client_test.go b/providers/azure/services/cache/client_test.go index a95814ed7..517c35bf7 100644 --- a/providers/azure/services/cache/client_test.go +++ b/providers/azure/services/cache/client_test.go @@ -1007,6 +1007,7 @@ func TestCacheClient_PurchaseCommitment_Success(t *testing.T) { Term: "1yr", Count: 1, CommitmentCost: 1000.0, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -1035,6 +1036,7 @@ func TestCacheClient_PurchaseCommitment_3YearTerm(t *testing.T) { Term: "3yr", Count: 1, CommitmentCost: 2500.0, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -1062,6 +1064,7 @@ func TestCacheClient_PurchaseCommitment_Accepted(t *testing.T) { Term: "1yr", Count: 1, CommitmentCost: 1000.0, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -1077,9 +1080,10 @@ func TestCacheClient_PurchaseCommitment_TokenError(t *testing.T) { client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) rec := common.Recommendation{ - ResourceType: "Premium_P1", - Term: "1yr", - Count: 1, + ResourceType: "Premium_P1", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -1099,9 +1103,10 @@ func TestCacheClient_PurchaseCommitment_HTTPError(t *testing.T) { })).Return(nil, errors.New("network error")).Once() rec := common.Recommendation{ - ResourceType: "Premium_P1", - Term: "1yr", - Count: 1, + ResourceType: "Premium_P1", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -1124,9 +1129,10 @@ func TestCacheClient_PurchaseCommitment_BadStatus(t *testing.T) { })).Return(createMockHTTPResponse(http.StatusBadRequest, `{"error": "invalid request"}`), nil).Once() rec := common.Recommendation{ - ResourceType: "Premium_P1", - Term: "1yr", - Count: 1, + ResourceType: "Premium_P1", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -1164,7 +1170,7 @@ func TestCacheClient_PurchaseCommitment_TagInjection(t *testing.T) { return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" })).Return(createMockHTTPResponse(http.StatusOK, `{}`), nil).Once() - rec := common.Recommendation{ResourceType: "Premium_P1", Term: "1yr", Count: 1, CommitmentCost: 1000.0} + rec := common.Recommendation{ResourceType: "Premium_P1", Term: "1yr", Count: 1, CommitmentCost: 1000.0, PaymentOption: "no-upfront"} result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: source}) require.NoError(t, err) assert.True(t, result.Success) @@ -1177,6 +1183,71 @@ func TestCacheClient_PurchaseCommitment_TagInjection(t *testing.T) { mockHTTP.AssertExpectations(t) } +// TestCacheClient_PurchaseCommitment_BillingPlan pins the billingPlan wiring +// (issue #1502, mirroring PR #1495's fix for compute): Azure Cache for Redis +// reserved capacity supports both Upfront and Monthly billing +// (learn.microsoft.com/azure/azure-cache-for-redis/cache-reserved-pricing), +// so rec.PaymentOption must map onto the correct armreservations +// ReservationBillingPlan value in the purchase body rather than silently +// defaulting to Azure's Upfront behavior for a no-upfront/monthly rec. +func TestCacheClient_PurchaseCommitment_BillingPlan(t *testing.T) { + cases := []struct { + name string + paymentOption string + wantPlan string + wantErrSub string + }{ + {name: "all-upfront maps to Upfront", paymentOption: "all-upfront", wantPlan: "Upfront"}, + {name: "no-upfront maps to Monthly", paymentOption: "no-upfront", wantPlan: "Monthly"}, + {name: "partial-upfront is rejected", paymentOption: "partial-upfront", wantErrSub: "partial-upfront has no azure equivalent"}, + {name: "empty payment option is rejected", paymentOption: "", wantErrSub: "azure reservations support only upfront or monthly billing"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + mockHTTP := &MockHTTPClient{} + mockCred := &MockTokenCredential{token: "test-token"} + client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) + + rec := common.Recommendation{ResourceType: "Premium_P1", Term: "1yr", Count: 1, PaymentOption: tc.paymentOption} + + if tc.wantErrSub != "" { + result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), tc.wantErrSub) + mockHTTP.AssertNotCalled(t, "Do", mock.Anything) + return + } + + const orderID = "cache-billingplan-test" + var capturedBody []byte + mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool { + if r.URL.Path != "/providers/Microsoft.Capacity/calculatePrice" { + return false + } + capturedBody, _ = io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(capturedBody)) + return true + })).Return(createMockHTTPResponse(http.StatusOK, calcPriceRespJSON(orderID)), nil).Once() + mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool { + return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" + })).Return(createMockHTTPResponse(http.StatusOK, `{}`), nil).Once() + + result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.NoError(t, err) + assert.True(t, result.Success) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(capturedBody, &body)) + props, ok := body["properties"].(map[string]interface{}) + require.True(t, ok, "properties map missing from reservation body") + assert.Equal(t, tc.wantPlan, props["billingPlan"]) + mockHTTP.AssertExpectations(t) + }) + } +} + // TestCacheClient_PurchaseCommitment_RequiresSource pins the dedupe guard: // PurchaseCommitment must reject an empty opts.Source before issuing any HTTP // call. Azure mints the reservation order ID server-side, so the @@ -1238,6 +1309,7 @@ func TestCacheClient_PurchaseCommitment_DisplayNameConformsToAzureAllowlist(t *t Term: "1yr", Count: 1, CommitmentCost: 1000.0, + PaymentOption: "no-upfront", } _, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.NoError(t, err) From 3889c345738b30d2176b128855424bf5f24b132c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:51:14 +0200 Subject: [PATCH 47/83] fix(azure): wire billingPlan into Cosmos DB reservation purchases Azure Cosmos DB reserved capacity supports both Upfront and Monthly billing (learn.microsoft.com/azure/cosmos-db/reserved-capacity). PurchaseCommitment never set properties.billingPlan, so a no-upfront recommendation was silently billed at Azure's Upfront default. Map rec.PaymentOption via the shared reservations.BillingPlanForPaymentOption helper (added in #1495 for compute) and fail loud on empty/unrecognized values instead of defaulting. Part of #1502. --- providers/azure/services/cosmosdb/client.go | 6 ++ .../azure/services/cosmosdb/client_test.go | 92 +++++++++++++++++-- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/providers/azure/services/cosmosdb/client.go b/providers/azure/services/cosmosdb/client.go index 620b35476..5c20f3d35 100644 --- a/providers/azure/services/cosmosdb/client.go +++ b/providers/azure/services/cosmosdb/client.go @@ -297,6 +297,11 @@ func (c *CosmosDBClient) PurchaseCommitment(ctx context.Context, rec common.Reco result.Error = termErr return result, result.Error } + billingPlan, billingPlanErr := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + if billingPlanErr != nil { + result.Error = billingPlanErr + return result, result.Error + } requestBody := map[string]interface{}{ "sku": map[string]string{ @@ -306,6 +311,7 @@ func (c *CosmosDBClient) PurchaseCommitment(ctx context.Context, rec common.Reco "properties": map[string]interface{}{ "reservedResourceType": string(armreservations.ReservedResourceTypeCosmosDb), "billingScopeId": fmt.Sprintf("/subscriptions/%s", c.subscriptionID), + "billingPlan": string(billingPlan), "term": fmt.Sprintf("P%dY", termYears), "quantity": rec.Count, "displayName": reservations.BuildDisplayName(reservations.DisplayNameFields{ diff --git a/providers/azure/services/cosmosdb/client_test.go b/providers/azure/services/cosmosdb/client_test.go index 5f75b1761..02630a61e 100644 --- a/providers/azure/services/cosmosdb/client_test.go +++ b/providers/azure/services/cosmosdb/client_test.go @@ -827,6 +827,7 @@ func TestCosmosDBClient_PurchaseCommitment_Success(t *testing.T) { ResourceType: "EnableCassandra", Term: "1yr", Count: 100, + PaymentOption: "no-upfront", CommitmentCost: 5000.0, } @@ -855,6 +856,7 @@ func TestCosmosDBClient_PurchaseCommitment_3YearTerm(t *testing.T) { ResourceType: "EnableCassandra", Term: "3yr", Count: 100, + PaymentOption: "no-upfront", CommitmentCost: 12000.0, } @@ -882,6 +884,7 @@ func TestCosmosDBClient_PurchaseCommitment_Accepted(t *testing.T) { ResourceType: "EnableCassandra", Term: "1yr", Count: 100, + PaymentOption: "no-upfront", CommitmentCost: 5000.0, } @@ -898,9 +901,10 @@ func TestCosmosDBClient_PurchaseCommitment_TokenError(t *testing.T) { client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) rec := common.Recommendation{ - ResourceType: "EnableCassandra", - Term: "1yr", - Count: 1, + ResourceType: "EnableCassandra", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -920,9 +924,10 @@ func TestCosmosDBClient_PurchaseCommitment_HTTPError(t *testing.T) { })).Return(nil, errors.New("network error")).Once() rec := common.Recommendation{ - ResourceType: "EnableCassandra", - Term: "1yr", - Count: 1, + ResourceType: "EnableCassandra", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -945,9 +950,10 @@ func TestCosmosDBClient_PurchaseCommitment_BadStatus(t *testing.T) { })).Return(createMockHTTPResponse(http.StatusBadRequest, `{"error": "invalid request"}`), nil).Once() rec := common.Recommendation{ - ResourceType: "EnableCassandra", - Term: "1yr", - Count: 1, + ResourceType: "EnableCassandra", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -985,7 +991,7 @@ func TestCosmosDBClient_PurchaseCommitment_TagInjection(t *testing.T) { return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" })).Return(createMockHTTPResponse(http.StatusOK, `{}`), nil).Once() - rec := common.Recommendation{ResourceType: "EnableCassandra", Term: "1yr", Count: 1, CommitmentCost: 4000.0} + rec := common.Recommendation{ResourceType: "EnableCassandra", Term: "1yr", Count: 1, CommitmentCost: 4000.0, PaymentOption: "no-upfront"} result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: source}) require.NoError(t, err) assert.True(t, result.Success) @@ -998,6 +1004,71 @@ func TestCosmosDBClient_PurchaseCommitment_TagInjection(t *testing.T) { mockHTTP.AssertExpectations(t) } +// TestCosmosDBClient_PurchaseCommitment_BillingPlan pins the billingPlan +// wiring (issue #1502, mirroring PR #1495's fix for compute): Azure Cosmos DB +// reserved capacity supports both Upfront and Monthly billing +// (learn.microsoft.com/azure/cosmos-db/reserved-capacity), so +// rec.PaymentOption must map onto the correct armreservations +// ReservationBillingPlan value in the purchase body rather than silently +// defaulting to Azure's Upfront behavior for a no-upfront/monthly rec. +func TestCosmosDBClient_PurchaseCommitment_BillingPlan(t *testing.T) { + cases := []struct { + name string + paymentOption string + wantPlan string + wantErrSub string + }{ + {name: "all-upfront maps to Upfront", paymentOption: "all-upfront", wantPlan: "Upfront"}, + {name: "no-upfront maps to Monthly", paymentOption: "no-upfront", wantPlan: "Monthly"}, + {name: "partial-upfront is rejected", paymentOption: "partial-upfront", wantErrSub: "partial-upfront has no azure equivalent"}, + {name: "empty payment option is rejected", paymentOption: "", wantErrSub: "azure reservations support only upfront or monthly billing"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + mockHTTP := &MockHTTPClient{} + mockCred := &MockTokenCredential{token: "test-token"} + client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) + + rec := common.Recommendation{ResourceType: "EnableCassandra", Term: "1yr", Count: 1, PaymentOption: tc.paymentOption} + + if tc.wantErrSub != "" { + result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), tc.wantErrSub) + mockHTTP.AssertNotCalled(t, "Do", mock.Anything) + return + } + + const orderID = "cosmos-billingplan-test" + var capturedBody []byte + mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool { + if r.URL.Path != "/providers/Microsoft.Capacity/calculatePrice" { + return false + } + capturedBody, _ = io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(capturedBody)) + return true + })).Return(createMockHTTPResponse(http.StatusOK, calcPriceRespJSON(orderID)), nil).Once() + mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool { + return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" + })).Return(createMockHTTPResponse(http.StatusOK, `{}`), nil).Once() + + result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.NoError(t, err) + assert.True(t, result.Success) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(capturedBody, &body)) + props, ok := body["properties"].(map[string]interface{}) + require.True(t, ok, "properties map missing from reservation body") + assert.Equal(t, tc.wantPlan, props["billingPlan"]) + mockHTTP.AssertExpectations(t) + }) + } +} + // TestCosmosDBClient_PurchaseCommitment_RequiresSource pins the dedupe guard: // PurchaseCommitment must reject an empty opts.Source before issuing any HTTP // call. Azure mints the reservation order ID server-side, so the @@ -1306,6 +1377,7 @@ func TestCosmosDBClient_PurchaseCommitment_DisplayNameConformsToAzureAllowlist(t ResourceType: "EnableCassandra", Term: "1yr", Count: 100, + PaymentOption: "no-upfront", CommitmentCost: 5000.0, } _, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) From 94daaa49606c8a0d27512e698340666638caa652 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:51:40 +0200 Subject: [PATCH 48/83] fix(azure): wire billingPlan into Search reservation purchases The Microsoft.Capacity calculatePrice/purchase API uses the same properties struct for every reservedResourceType, and Azure's monthly- payments doc (learn.microsoft.com/azure/cost-management-billing/reservations/ prepare-buy-reservation) excludes only SUSE Linux, Red Hat plans, Azure Red Hat OpenShift, and pre-purchase plans from Monthly billing, so Search is covered the same as every other sibling service. PurchaseCommitment never set properties.billingPlan, so a no-upfront recommendation was silently billed at Azure's Upfront default. Map rec.PaymentOption via the shared reservations.BillingPlanForPaymentOption helper (added in #1495 for compute) and fail loud on empty/unrecognized values instead of defaulting. This is independent of the pre-existing "SearchService" reservedResourceType literal being unverified against the live catalog (issue #1189). Part of #1502. --- providers/azure/services/search/client.go | 6 ++ .../azure/services/search/client_test.go | 97 +++++++++++++++++-- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/providers/azure/services/search/client.go b/providers/azure/services/search/client.go index 2d02c19aa..eebdea10f 100644 --- a/providers/azure/services/search/client.go +++ b/providers/azure/services/search/client.go @@ -242,6 +242,11 @@ func (c *SearchClient) PurchaseCommitment(ctx context.Context, rec common.Recomm result.Error = termErr return result, result.Error } + billingPlan, billingPlanErr := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + if billingPlanErr != nil { + result.Error = billingPlanErr + return result, result.Error + } requestBody := map[string]interface{}{ "sku": map[string]string{ @@ -256,6 +261,7 @@ func (c *SearchClient) PurchaseCommitment(ctx context.Context, rec common.Recomm // live reservation catalog (see issue #1189). "reservedResourceType": "SearchService", "billingScopeId": fmt.Sprintf("/subscriptions/%s", c.subscriptionID), + "billingPlan": string(billingPlan), "term": fmt.Sprintf("P%dY", termYears), "quantity": rec.Count, "displayName": reservations.BuildDisplayName(reservations.DisplayNameFields{ diff --git a/providers/azure/services/search/client_test.go b/providers/azure/services/search/client_test.go index 94ab59196..a01090554 100644 --- a/providers/azure/services/search/client_test.go +++ b/providers/azure/services/search/client_test.go @@ -743,6 +743,7 @@ func TestSearchClient_PurchaseCommitment_Success(t *testing.T) { Term: "1yr", Count: 1, CommitmentCost: 3000.0, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -771,6 +772,7 @@ func TestSearchClient_PurchaseCommitment_3YearTerm(t *testing.T) { Term: "3yr", Count: 1, CommitmentCost: 7500.0, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -798,6 +800,7 @@ func TestSearchClient_PurchaseCommitment_Accepted(t *testing.T) { Term: "1yr", Count: 1, CommitmentCost: 3000.0, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -813,9 +816,10 @@ func TestSearchClient_PurchaseCommitment_TokenError(t *testing.T) { client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) rec := common.Recommendation{ - ResourceType: "standard", - Term: "1yr", - Count: 1, + ResourceType: "standard", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -835,9 +839,10 @@ func TestSearchClient_PurchaseCommitment_HTTPError(t *testing.T) { })).Return(nil, errors.New("network error")).Once() rec := common.Recommendation{ - ResourceType: "standard", - Term: "1yr", - Count: 1, + ResourceType: "standard", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -860,9 +865,10 @@ func TestSearchClient_PurchaseCommitment_BadStatus(t *testing.T) { })).Return(createMockHTTPResponse(http.StatusBadRequest, `{"error": "invalid request"}`), nil).Once() rec := common.Recommendation{ - ResourceType: "standard", - Term: "1yr", - Count: 1, + ResourceType: "standard", + Term: "1yr", + Count: 1, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -954,6 +960,7 @@ func TestSearchClient_PurchaseCommitment_TwoStepFlow(t *testing.T) { Term: "1yr", Count: 1, CommitmentCost: 3000.0, + PaymentOption: "no-upfront", } result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) @@ -993,7 +1000,7 @@ func TestSearchClient_PurchaseCommitment_TagInjection(t *testing.T) { r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" })).Return(createMockHTTPResponse(http.StatusOK, `{}`), nil).Once() - rec := common.Recommendation{ResourceType: "standard", Term: "1yr", Count: 1, CommitmentCost: 3000.0} + rec := common.Recommendation{ResourceType: "standard", Term: "1yr", Count: 1, CommitmentCost: 3000.0, PaymentOption: "no-upfront"} result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: source}) require.NoError(t, err) assert.True(t, result.Success) @@ -1006,6 +1013,75 @@ func TestSearchClient_PurchaseCommitment_TagInjection(t *testing.T) { mockHTTP.AssertExpectations(t) } +// TestSearchClient_PurchaseCommitment_BillingPlan pins the billingPlan +// wiring (issue #1502, mirroring PR #1495's fix for compute). The billingPlan +// property is generic to the Microsoft.Capacity purchase API (same +// properties struct for every reservedResourceType); Microsoft's Monthly +// payments doc (learn.microsoft.com/azure/cost-management-billing/ +// reservations/prepare-buy-reservation#buy-reservations-with-monthly-payments) +// lists an explicit exclusion list (SUSE Linux, Red Hat plans, Azure Red Hat +// OpenShift, pre-purchase plans) that does not include Search, so Monthly is +// wired the same as every other sibling service. This is independent of the +// pre-existing "SearchService" reservedResourceType literal being unverified +// against the live catalog (issue #1189) -- that is a different property. +func TestSearchClient_PurchaseCommitment_BillingPlan(t *testing.T) { + cases := []struct { + name string + paymentOption string + wantPlan string + wantErrSub string + }{ + {name: "all-upfront maps to Upfront", paymentOption: "all-upfront", wantPlan: "Upfront"}, + {name: "no-upfront maps to Monthly", paymentOption: "no-upfront", wantPlan: "Monthly"}, + {name: "partial-upfront is rejected", paymentOption: "partial-upfront", wantErrSub: "partial-upfront has no azure equivalent"}, + {name: "empty payment option is rejected", paymentOption: "", wantErrSub: "azure reservations support only upfront or monthly billing"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + mockHTTP := &MockHTTPClient{} + mockCred := &MockTokenCredential{token: "test-token"} + client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP) + + rec := common.Recommendation{ResourceType: "standard", Term: "1yr", Count: 1, PaymentOption: tc.paymentOption} + + if tc.wantErrSub != "" { + result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), tc.wantErrSub) + mockHTTP.AssertNotCalled(t, "Do", mock.Anything) + return + } + + const orderID = "search-billingplan-test" + var capturedBody []byte + mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool { + if r.URL.Path != "/providers/Microsoft.Capacity/calculatePrice" { + return false + } + capturedBody, _ = io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(capturedBody)) + return true + })).Return(createMockHTTPResponse(http.StatusOK, calcPriceRespJSON(orderID)), nil).Once() + mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool { + return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" + })).Return(createMockHTTPResponse(http.StatusOK, `{}`), nil).Once() + + result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.NoError(t, err) + assert.True(t, result.Success) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(capturedBody, &body)) + props, ok := body["properties"].(map[string]interface{}) + require.True(t, ok, "properties map missing from reservation body") + assert.Equal(t, tc.wantPlan, props["billingPlan"]) + mockHTTP.AssertExpectations(t) + }) + } +} + // TestSearchClient_PurchaseCommitment_RequiresSource pins the dedupe guard: // PurchaseCommitment must reject an empty opts.Source before issuing any HTTP // call. Azure mints the reservation order ID server-side, so the @@ -1101,6 +1177,7 @@ func TestSearchClient_PurchaseCommitment_DisplayNameConformsToAzureAllowlist(t * Term: "1yr", Count: 1, CommitmentCost: 800.0, + PaymentOption: "no-upfront", } _, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.NoError(t, err) From fdb36a228df1f1788fa72c72441d704a70aeb664 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:52:04 +0200 Subject: [PATCH 49/83] fix(azure): wire billingPlan into Managed Redis reservation purchases Azure Managed Redis reservations support both Upfront and Monthly billing frequency (learn.microsoft.com/azure/redis/reserved-pricing). PurchaseCommitment never set properties.billingPlan, so a no-upfront recommendation was silently billed at Azure's Upfront default. Map rec.PaymentOption via the shared reservations.BillingPlanForPaymentOption helper (added in #1495 for compute) and fail loud on empty/unrecognized values instead of defaulting. Part of #1502. --- .../azure/services/managedredis/client.go | 6 ++ .../services/managedredis/client_test.go | 72 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/providers/azure/services/managedredis/client.go b/providers/azure/services/managedredis/client.go index 92d77b468..5bb98d5c1 100644 --- a/providers/azure/services/managedredis/client.go +++ b/providers/azure/services/managedredis/client.go @@ -264,6 +264,11 @@ func (c *ManagedRedisClient) PurchaseCommitment(ctx context.Context, rec common. result.Error = termErr return result, result.Error } + billingPlan, billingPlanErr := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + if billingPlanErr != nil { + result.Error = billingPlanErr + return result, result.Error + } requestBody := map[string]interface{}{ "sku": map[string]string{ @@ -273,6 +278,7 @@ func (c *ManagedRedisClient) PurchaseCommitment(ctx context.Context, rec common. "properties": map[string]interface{}{ "reservedResourceType": string(armreservations.ReservedResourceTypeRedisCache), "billingScopeId": fmt.Sprintf("/subscriptions/%s", c.subscriptionID), + "billingPlan": string(billingPlan), "term": fmt.Sprintf("P%dY", termYears), "quantity": rec.Count, "displayName": fmt.Sprintf("Azure Cache for Redis Reservation - %s", rec.ResourceType), diff --git a/providers/azure/services/managedredis/client_test.go b/providers/azure/services/managedredis/client_test.go index f0d48ad30..48b22cf91 100644 --- a/providers/azure/services/managedredis/client_test.go +++ b/providers/azure/services/managedredis/client_test.go @@ -582,6 +582,7 @@ func TestPurchaseCommitment_Success(t *testing.T) { c := NewClientWithHTTP(cred, "sub", "eastus", h) result, err := c.PurchaseCommitment(context.Background(), common.Recommendation{ ResourceType: "Premium_P1", Term: "1yr", Count: 1, CommitmentCost: 500.0, + PaymentOption: "no-upfront", }, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.NoError(t, err) assert.True(t, result.Success) @@ -602,6 +603,7 @@ func TestPurchaseCommitment_3yr(t *testing.T) { c := NewClientWithHTTP(cred, "sub", "eastus", h) result, err := c.PurchaseCommitment(context.Background(), common.Recommendation{ ResourceType: "Premium_P2", Term: "3yr", Count: 2, CommitmentCost: 1200.0, + PaymentOption: "no-upfront", }, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.NoError(t, err) assert.True(t, result.Success) @@ -621,6 +623,7 @@ func TestPurchaseCommitment_Accepted(t *testing.T) { c := NewClientWithHTTP(cred, "sub", "eastus", h) result, err := c.PurchaseCommitment(context.Background(), common.Recommendation{ ResourceType: "Premium_P1", Term: "1yr", Count: 1, + PaymentOption: "no-upfront", }, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.NoError(t, err) assert.True(t, result.Success) @@ -633,6 +636,7 @@ func TestPurchaseCommitment_TokenError(t *testing.T) { c := NewClientWithHTTP(cred, "sub", "eastus", h) result, err := c.PurchaseCommitment(context.Background(), common.Recommendation{ ResourceType: "Premium_P1", Term: "1yr", Count: 1, + PaymentOption: "no-upfront", }, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.Error(t, err) assert.False(t, result.Success) @@ -649,6 +653,7 @@ func TestPurchaseCommitment_HTTPError(t *testing.T) { c := NewClientWithHTTP(cred, "sub", "eastus", h) result, err := c.PurchaseCommitment(context.Background(), common.Recommendation{ ResourceType: "Premium_P1", Term: "1yr", Count: 1, + PaymentOption: "no-upfront", }, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.Error(t, err) assert.False(t, result.Success) @@ -668,6 +673,7 @@ func TestPurchaseCommitment_BadStatus(t *testing.T) { c := NewClientWithHTTP(cred, "sub", "eastus", h) result, err := c.PurchaseCommitment(context.Background(), common.Recommendation{ ResourceType: "Premium_P1", Term: "1yr", Count: 1, + PaymentOption: "no-upfront", }, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.Error(t, err) assert.False(t, result.Success) @@ -686,6 +692,71 @@ func TestPurchaseCommitment_InvalidTerm(t *testing.T) { assert.Contains(t, err.Error(), "unsupported reservation term") } +// TestPurchaseCommitment_BillingPlan pins the billingPlan wiring (issue +// #1502, mirroring PR #1495's fix for compute): Azure Managed Redis +// reservations support both Upfront and Monthly billing frequency +// (learn.microsoft.com/azure/redis/reserved-pricing), so rec.PaymentOption +// must map onto the correct armreservations ReservationBillingPlan value in +// the purchase body rather than silently defaulting to Azure's Upfront +// behavior for a no-upfront/monthly rec. +func TestPurchaseCommitment_BillingPlan(t *testing.T) { + cases := []struct { + name string + paymentOption string + wantPlan string + wantErrSub string + }{ + {name: "all-upfront maps to Upfront", paymentOption: "all-upfront", wantPlan: "Upfront"}, + {name: "no-upfront maps to Monthly", paymentOption: "no-upfront", wantPlan: "Monthly"}, + {name: "partial-upfront is rejected", paymentOption: "partial-upfront", wantErrSub: "partial-upfront has no azure equivalent"}, + {name: "empty payment option is rejected", paymentOption: "", wantErrSub: "azure reservations support only upfront or monthly billing"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := &mocks.MockHTTPClient{} + t.Cleanup(func() { h.AssertExpectations(t) }) + cred := &mockTokenCredential{token: "tok"} + c := NewClientWithHTTP(cred, "sub", "eastus", h) + + rec := common.Recommendation{ResourceType: "Premium_P1", Term: "1yr", Count: 1, PaymentOption: tc.paymentOption} + + if tc.wantErrSub != "" { + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), tc.wantErrSub) + h.AssertNotCalled(t, "Do", mock.Anything) + return + } + + const orderID = "mr-billingplan-test" + h.On("Do", mock.MatchedBy(func(r *http.Request) bool { + return r.URL.Path == "/providers/Microsoft.Capacity/calculatePrice" + })).Return(mocks.CreateMockHTTPResponse(http.StatusOK, calcPriceRespJSON(orderID)), nil).Once() + + var capturedBody []byte + h.On("Do", mock.MatchedBy(func(r *http.Request) bool { + if r.URL.Path != "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" { + return false + } + capturedBody, _ = io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(capturedBody)) + return true + })).Return(mocks.CreateMockHTTPResponse(http.StatusOK, `{}`), nil).Once() + + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.NoError(t, err) + assert.True(t, result.Success) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(capturedBody, &body)) + props, ok := body["properties"].(map[string]interface{}) + require.True(t, ok, "properties map missing from reservation body") + assert.Equal(t, tc.wantPlan, props["billingPlan"]) + }) + } +} + // TestPurchaseCommitment_RequiresSource pins the dedupe guard: // PurchaseCommitment must reject an empty opts.Source before issuing any HTTP // call. Azure mints the reservation order ID server-side, so the @@ -824,6 +895,7 @@ func TestPurchaseCommitment_TagInjection(t *testing.T) { result, err := c.PurchaseCommitment(context.Background(), common.Recommendation{ ResourceType: "Premium_P1", Term: "1yr", Count: 1, CommitmentCost: 500.0, + PaymentOption: "no-upfront", }, common.PurchaseOptions{Source: source}) require.NoError(t, err) assert.True(t, result.Success) From b50d7a45e49dfc8da9d5b8b70fda7036b2fa2e14 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:52:30 +0200 Subject: [PATCH 50/83] fix(azure): wire billingPlan into Synapse reservation purchases Azure Synapse Analytics Dedicated SQL pool (SQL DW) reserved capacity supports both Upfront and Monthly billing (learn.microsoft.com/azure/ cost-management-billing/reservations/prepay-sql-data-warehouse-charges). PurchaseCommitment never set properties.billingPlan, so a no-upfront recommendation was silently billed at Azure's Upfront default. Map rec.PaymentOption via the shared reservations.BillingPlanForPaymentOption helper (added in #1495 for compute) and fail loud on empty/unrecognized values instead of defaulting. Part of #1502. --- providers/azure/services/synapse/client.go | 6 ++ .../azure/services/synapse/client_test.go | 76 ++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/providers/azure/services/synapse/client.go b/providers/azure/services/synapse/client.go index 876c65600..c79cc0a85 100644 --- a/providers/azure/services/synapse/client.go +++ b/providers/azure/services/synapse/client.go @@ -270,6 +270,11 @@ func (c *SynapseClient) PurchaseCommitment(ctx context.Context, rec common.Recom result.Error = err return result, result.Error } + billingPlan, err := reservations.BillingPlanForPaymentOption(rec.PaymentOption) + if err != nil { + result.Error = err + return result, result.Error + } requestBody := map[string]interface{}{ "sku": map[string]string{ @@ -279,6 +284,7 @@ func (c *SynapseClient) PurchaseCommitment(ctx context.Context, rec common.Recom "properties": map[string]interface{}{ "reservedResourceType": string(armreservations.ReservedResourceTypeSQLDataWarehouse), "billingScopeId": fmt.Sprintf("/subscriptions/%s", c.subscriptionID), + "billingPlan": string(billingPlan), "term": fmt.Sprintf("P%dY", termYears), "quantity": rec.Count, "displayName": fmt.Sprintf("Synapse SQL Pool Reservation - %s", rec.ResourceType), diff --git a/providers/azure/services/synapse/client_test.go b/providers/azure/services/synapse/client_test.go index c58bb98c5..7bd8abcd4 100644 --- a/providers/azure/services/synapse/client_test.go +++ b/providers/azure/services/synapse/client_test.go @@ -502,6 +502,7 @@ func TestPurchaseCommitment_success(t *testing.T) { Term: "1yr", Count: 1, CommitmentCost: 5000.0, + PaymentOption: "no-upfront", } result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.NoError(t, err) @@ -523,7 +524,7 @@ func TestPurchaseCommitment_3yrTerm(t *testing.T) { cred := &mockTokenCredential{token: "test-token"} c := NewClientWithHTTP(cred, "sub-123", "eastus", mHTTP) - rec := common.Recommendation{ResourceType: "DW500c", Term: "3yr", Count: 2, CommitmentCost: 9000.0} + rec := common.Recommendation{ResourceType: "DW500c", Term: "3yr", Count: 2, CommitmentCost: 9000.0, PaymentOption: "no-upfront"} result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.NoError(t, err) assert.True(t, result.Success) @@ -550,7 +551,7 @@ func TestPurchaseCommitment_withSource(t *testing.T) { cred := &mockTokenCredential{token: "test-token"} c := NewClientWithHTTP(cred, "sub-123", "eastus", mHTTP) - rec := common.Recommendation{ResourceType: "DW500c", Term: "1yr", Count: 1} + rec := common.Recommendation{ResourceType: "DW500c", Term: "1yr", Count: 1, PaymentOption: "no-upfront"} _, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: "automation"}) require.NoError(t, err) assert.Contains(t, string(capturedBody), "purchase-automation") @@ -571,7 +572,7 @@ func TestPurchaseCommitment_apiError(t *testing.T) { cred := &mockTokenCredential{token: "test-token"} c := NewClientWithHTTP(cred, "sub-123", "eastus", mHTTP) - rec := common.Recommendation{ResourceType: "DW1000c", Term: "1yr", Count: 1} + rec := common.Recommendation{ResourceType: "DW1000c", Term: "1yr", Count: 1, PaymentOption: "no-upfront"} result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.Error(t, err) assert.False(t, result.Success) @@ -588,7 +589,7 @@ func TestPurchaseCommitment_httpError(t *testing.T) { cred := &mockTokenCredential{token: "test-token"} c := NewClientWithHTTP(cred, "sub-123", "eastus", mHTTP) - rec := common.Recommendation{ResourceType: "DW1000c", Term: "1yr", Count: 1} + rec := common.Recommendation{ResourceType: "DW1000c", Term: "1yr", Count: 1, PaymentOption: "no-upfront"} result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.Error(t, err) assert.False(t, result.Success) @@ -784,6 +785,72 @@ func TestPurchaseCommitment_unsupportedTerm(t *testing.T) { assert.Contains(t, err.Error(), "unsupported reservation term") } +// TestPurchaseCommitment_billingPlan pins the billingPlan wiring (issue +// #1502, mirroring PR #1495's fix for compute): Azure Synapse Analytics +// Dedicated SQL pool (SQL DW) reserved capacity supports both Upfront and +// Monthly billing (learn.microsoft.com/azure/cost-management-billing/ +// reservations/prepay-sql-data-warehouse-charges), so rec.PaymentOption must +// map onto the correct armreservations ReservationBillingPlan value in the +// purchase body rather than silently defaulting to Azure's Upfront behavior +// for a no-upfront/monthly rec. +func TestPurchaseCommitment_billingPlan(t *testing.T) { + cases := []struct { + name string + paymentOption string + wantPlan string + wantErrSub string + }{ + {name: "all-upfront maps to Upfront", paymentOption: "all-upfront", wantPlan: "Upfront"}, + {name: "no-upfront maps to Monthly", paymentOption: "no-upfront", wantPlan: "Monthly"}, + {name: "partial-upfront is rejected", paymentOption: "partial-upfront", wantErrSub: "partial-upfront has no azure equivalent"}, + {name: "empty payment option is rejected", paymentOption: "", wantErrSub: "azure reservations support only upfront or monthly billing"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mHTTP := &mocks.MockHTTPClient{} + t.Cleanup(func() { mHTTP.AssertExpectations(t) }) + cred := &mockTokenCredential{token: "test-token"} + c := NewClientWithHTTP(cred, "sub-123", "eastus", mHTTP) + + rec := common.Recommendation{ResourceType: "DW1000c", Term: "1yr", Count: 1, PaymentOption: tc.paymentOption} + + if tc.wantErrSub != "" { + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), tc.wantErrSub) + mHTTP.AssertNotCalled(t, "Do", mock.Anything) + return + } + + const orderID = "syn-billingplan-test" + mHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool { + return r.URL.Path == "/providers/Microsoft.Capacity/calculatePrice" + })).Return(mocks.CreateMockHTTPResponse(http.StatusOK, calcPriceRespJSON(orderID)), nil).Once() + + var capturedBody []byte + mHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool { + if r.URL.Path != "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase" { + return false + } + capturedBody, _ = io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(capturedBody)) + return true + })).Return(mocks.CreateMockHTTPResponse(http.StatusOK, `{}`), nil).Once() + + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) + require.NoError(t, err) + assert.True(t, result.Success) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(capturedBody, &body)) + props, ok := body["properties"].(map[string]interface{}) + require.True(t, ok, "properties map missing from reservation body") + assert.Equal(t, tc.wantPlan, props["billingPlan"]) + }) + } +} + // TestPurchaseCommitment_requiresSource pins the dedupe guard: // PurchaseCommitment must reject an empty opts.Source before issuing any HTTP // call. Azure mints the reservation order ID server-side, so the @@ -880,6 +947,7 @@ func TestPurchaseCommitment_canonicalReservedResourceType(t *testing.T) { Term: "1yr", Count: 1, CommitmentCost: 5000.0, + PaymentOption: "no-upfront", } _, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI}) require.NoError(t, err) From a632eddabf3f59016c09c58883529cabbafe8f43 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 26 Jul 2026 01:13:42 +0200 Subject: [PATCH 51/83] fix(mcp): default Savings Plans search to 1yr no-upfront 30d AWS's GetSavingsPlansPurchaseRecommendation requires term, payment option, and lookback period on every call, unlike GetReservationPurchaseRecommendation (EC2/RDS/etc), which defaults them server-side when omitted. cudly_search_recommendations advertised these three fields as optional for every service, so an AWS Savings Plans search omitting them failed against Cost Explorer one field at a time. When the search targets an AWS Savings Plans service and the caller left a field blank, default payment_option to no-upfront, term_years to 1, and lookback_period to 30d before validating and building the provider call. A caller-supplied value is never overridden, and EC2/RDS/etc searches are unaffected. Update the jsonschema descriptions to state the per-service default/omit behavior instead of a blanket "omit to search all". --- mcp/tools/search_recommendations.go | 66 ++++++++++++++++---- mcp/tools/search_recommendations_test.go | 76 ++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index e5398cc12..548fe79c0 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -28,9 +28,9 @@ type searchRecommendationsArgs struct { Region string `json:"region,omitempty" jsonschema:"region to search; omit for account/global-level services such as Savings Plans"` IncludeRegions []string `json:"include_regions,omitempty" jsonschema:"restrict the search to these regions, in addition to (or instead of) region"` ExcludeRegions []string `json:"exclude_regions,omitempty" jsonschema:"exclude these regions from the search"` - LookbackPeriod string `json:"lookback_period,omitempty" jsonschema:"cost/usage lookback window backing the recommendation"` - TermYears int `json:"term_years,omitempty" jsonschema:"filter to a specific commitment term; omit to search all terms"` - PaymentOption string `json:"payment_option,omitempty" jsonschema:"filter to a specific payment schedule; omit to search all"` + LookbackPeriod string `json:"lookback_period,omitempty" jsonschema:"cost/usage lookback window backing the recommendation; AWS Savings Plans searches default to 30d when omitted (AWS requires a value); reservation searches (EC2/RDS/etc) leave it omitted to search all lookback windows"` + TermYears int `json:"term_years,omitempty" jsonschema:"commitment term filter; AWS Savings Plans searches default to 1 (1yr, no-upfront) when omitted (AWS requires a value); reservation searches (EC2/RDS/etc) leave it omitted to search all terms"` + PaymentOption string `json:"payment_option,omitempty" jsonschema:"payment schedule filter; AWS Savings Plans searches default to no-upfront when omitted (AWS requires a value); reservation searches (EC2/RDS/etc) leave it omitted to search all payment options"` AccountFilter []string `json:"account_filter,omitempty" jsonschema:"restrict the search to these account/subscription/project IDs"` IncludeSPTypes []string `json:"include_sp_types,omitempty" jsonschema:"AWS Savings Plans types to include; omit for all"` ExcludeSPTypes []string `json:"exclude_sp_types,omitempty" jsonschema:"AWS Savings Plans types to exclude"` @@ -86,7 +86,7 @@ func (t *searchRecommendationsTool) Register(s *mcp.Server) error { } func (t *searchRecommendationsTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args searchRecommendationsArgs) (*mcp.CallToolResult, searchRecommendationsResult, error) { - providerType, term, err := validateSearchArgs(args) + providerType, term, args, err := validateSearchArgs(args) if err != nil { return nil, searchRecommendationsResult{}, err } @@ -118,38 +118,78 @@ func (t *searchRecommendationsTool) handle(ctx context.Context, _ *mcp.CallToolR // validateSearchArgs validates every money-neutral-but-still-typed field on // args that does not require a live provider (provider name, payment // option, lookback_period, term, Savings Plans type filters), returning the -// typed provider name and the normalised Recommendation term string -// ("1yr"/"3yr", or "" when args.TermYears was omitted). -func validateSearchArgs(args searchRecommendationsArgs) (common.ProviderType, string, error) { +// typed provider name, the normalised Recommendation term string +// ("1yr"/"3yr", or "" when args.TermYears was omitted), and args with +// applySavingsPlansSearchDefaults applied. +func validateSearchArgs(args searchRecommendationsArgs) (common.ProviderType, string, searchRecommendationsArgs, error) { providerType, err := validateProviderName(args.Provider) if err != nil { - return "", "", err + return "", "", args, err } + args = applySavingsPlansSearchDefaults(providerType, args) if args.PaymentOption != "" { if _, err := ValidatePaymentOption(args.PaymentOption); err != nil { - return "", "", err + return "", "", args, err } } if _, err := ValidateLookbackPeriod(args.LookbackPeriod); err != nil { - return "", "", err + return "", "", args, err } term := "" if args.TermYears != 0 { ty, err := ValidateTermYears(args.TermYears) if err != nil { - return "", "", err + return "", "", args, err } term = ty.RecommendationTerm() } if err := validateSPTypeFilters(args.IncludeSPTypes, args.ExcludeSPTypes); err != nil { - return "", "", err + return "", "", args, err } - return providerType, term, nil + return providerType, term, args, nil +} + +// isAWSSavingsPlansSearch reports whether args targets an AWS Savings Plans +// search -- the only combination where AWS's +// GetSavingsPlansPurchaseRecommendation requires term_years, payment_option, +// and lookback_period on every call. GetReservationPurchaseRecommendation +// (EC2/RDS/etc) defaults these server-side when omitted, so detection must +// never broaden past the Savings Plans family. Checked straight off the raw +// args -- no live provider needed -- via the same common.IsSavingsPlan +// family predicate the AWS recommendations client itself dispatches on +// (providers/aws/recommendations/client.go). +func isAWSSavingsPlansSearch(providerType common.ProviderType, service string) bool { + return providerType == common.ProviderAWS && common.IsSavingsPlan(common.ServiceType(service)) +} + +// applySavingsPlansSearchDefaults defaults payment_option, term_years, and +// lookback_period to no-upfront/1yr/30d when args targets an AWS Savings +// Plans search and the caller omitted them. Without this, an AWS SP search +// with these fields blank fails against Cost Explorer one field at a time +// (issue #1506): GetSavingsPlansPurchaseRecommendation requires all three, +// unlike the EC2/RDS reservation path this tool otherwise shares, which +// defaults them server-side. A caller-supplied value always wins -- this +// only fills in what was left blank. No-op for every other provider/service +// combination. +func applySavingsPlansSearchDefaults(providerType common.ProviderType, args searchRecommendationsArgs) searchRecommendationsArgs { + if !isAWSSavingsPlansSearch(providerType, args.Service) { + return args + } + if args.PaymentOption == "" { + args.PaymentOption = string(PaymentOptionNoUpfront) + } + if args.TermYears == 0 { + args.TermYears = int(TermOneYear) + } + if args.LookbackPeriod == "" { + args.LookbackPeriod = string(LookbackPeriod30Days) + } + return args } // validateSPTypeFilters validates every entry of include/exclude against diff --git a/mcp/tools/search_recommendations_test.go b/mcp/tools/search_recommendations_test.go index 31bf161f4..0f016d308 100644 --- a/mcp/tools/search_recommendations_test.go +++ b/mcp/tools/search_recommendations_test.go @@ -18,9 +18,11 @@ type fakeRecommendationsClient struct { lastParams *common.RecommendationParams recs []common.Recommendation err error + calls int } func (f *fakeRecommendationsClient) GetRecommendations(_ context.Context, params *common.RecommendationParams) ([]common.Recommendation, error) { + f.calls++ f.lastParams = params return f.recs, f.err } @@ -259,3 +261,77 @@ func TestSearchRecommendationsClientErrorSurfaced(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "Cost Explorer API throttled") } + +// TestSearchRecommendationsSPDefaultsAppliedWhenOmitted is the regression +// guard for issue #1506: AWS's GetSavingsPlansPurchaseRecommendation +// requires term/payment_option/lookback_period on every call, unlike +// GetReservationPurchaseRecommendation (EC2/RDS/etc), which defaults them +// server-side. Before the fix, an SP search omitting these three fields +// forwarded them blank and only succeeds after AWS rejects the request (in +// production, that means a caller must retry with each field filled in, one +// at a time). This proves a single search call now builds the +// no-upfront/1yr/30d defaults and reaches the provider exactly once. +func TestSearchRecommendationsSPDefaultsAppliedWhenOmitted(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceSavingsPlansAll}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: string(common.ServiceSavingsPlansAll), + }) + + require.NoError(t, err) + assert.Equal(t, 1, client.calls, "must reach the provider exactly once") + require.NotNil(t, client.lastParams) + assert.Equal(t, "1yr", client.lastParams.Term) + assert.Equal(t, "no-upfront", client.lastParams.PaymentOption) + assert.Equal(t, "30d", client.lastParams.LookbackPeriod) +} + +// TestSearchRecommendationsSPExplicitTermYearsPreserved proves the SP +// defaults never override an explicitly supplied value: term_years=3 must +// reach the provider as "3yr", not the 1yr default. +func TestSearchRecommendationsSPExplicitTermYearsPreserved(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceSavingsPlansAll}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: string(common.ServiceSavingsPlansAll), + TermYears: 3, + }) + + require.NoError(t, err) + require.NotNil(t, client.lastParams) + assert.Equal(t, "3yr", client.lastParams.Term) + assert.Equal(t, "no-upfront", client.lastParams.PaymentOption, "payment_option still defaults when omitted") + assert.Equal(t, "30d", client.lastParams.LookbackPeriod, "lookback_period still defaults when omitted") +} + +// TestSearchRecommendationsEC2NoDefaultsInjected proves the SP defaults are +// scoped to AWS Savings Plans searches only: an EC2 (reservation) search +// omitting term/payment/lookback must reach the provider with those fields +// still blank, since GetReservationPurchaseRecommendation already searches +// all terms/payment options/lookback windows when they're omitted -- +// defaulting them here would wrongly narrow those results. +func TestSearchRecommendationsEC2NoDefaultsInjected(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + }) + + require.NoError(t, err) + require.NotNil(t, client.lastParams) + assert.Empty(t, client.lastParams.Term) + assert.Empty(t, client.lastParams.PaymentOption) + assert.Empty(t, client.lastParams.LookbackPeriod) +} From 939b682bfc4082e9a47335edcb2f64dfbfde9f23 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 26 Jul 2026 01:16:25 +0200 Subject: [PATCH 52/83] fix(mcp): join all invalid search fields into one error validateSearchArgs returned on the first invalid field it found, so a caller with several bad fields had to fix and resubmit repeatedly to discover the next one. Collect every payment_option/lookback_period/ term_years/sp_type validation failure and errors.Join them into a single returned error instead. Also add requireSavingsPlansSearchFields as a safety net: after the Savings Plans defaults from the previous commit run, an AWS SP search should never still be missing payment_option/term_years/ lookback_period. If it somehow is, name every missing field in one error rather than letting the request reach Cost Explorer and fail one field at a time. --- mcp/tools/search_recommendations.go | 57 +++++++++++++++++++++--- mcp/tools/search_recommendations_test.go | 23 ++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index 548fe79c0..77e6ee47f 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -2,6 +2,7 @@ package tools import ( "context" + "errors" "fmt" "strings" @@ -121,6 +122,11 @@ func (t *searchRecommendationsTool) handle(ctx context.Context, _ *mcp.CallToolR // typed provider name, the normalised Recommendation term string // ("1yr"/"3yr", or "" when args.TermYears was omitted), and args with // applySavingsPlansSearchDefaults applied. +// +// Every invalid or missing field is collected and joined into a single +// error rather than returning on the first one found, so a caller missing +// several required fields (see requireSavingsPlansSearchFields) sees all of +// them at once instead of fixing them one call at a time. func validateSearchArgs(args searchRecommendationsArgs) (common.ProviderType, string, searchRecommendationsArgs, error) { providerType, err := validateProviderName(args.Provider) if err != nil { @@ -128,27 +134,37 @@ func validateSearchArgs(args searchRecommendationsArgs) (common.ProviderType, st } args = applySavingsPlansSearchDefaults(providerType, args) + var errs []error + if args.PaymentOption != "" { if _, err := ValidatePaymentOption(args.PaymentOption); err != nil { - return "", "", args, err + errs = append(errs, err) } } if _, err := ValidateLookbackPeriod(args.LookbackPeriod); err != nil { - return "", "", args, err + errs = append(errs, err) } term := "" if args.TermYears != 0 { - ty, err := ValidateTermYears(args.TermYears) - if err != nil { - return "", "", args, err + if ty, err := ValidateTermYears(args.TermYears); err != nil { + errs = append(errs, err) + } else { + term = ty.RecommendationTerm() } - term = ty.RecommendationTerm() } if err := validateSPTypeFilters(args.IncludeSPTypes, args.ExcludeSPTypes); err != nil { - return "", "", args, err + errs = append(errs, err) + } + + if err := requireSavingsPlansSearchFields(providerType, args); err != nil { + errs = append(errs, err) + } + + if len(errs) > 0 { + return "", "", args, errors.Join(errs...) } return providerType, term, args, nil @@ -192,6 +208,33 @@ func applySavingsPlansSearchDefaults(providerType common.ProviderType, args sear return args } +// requireSavingsPlansSearchFields is a validate-all-at-once safety net: after +// applySavingsPlansSearchDefaults runs, an AWS Savings Plans search should +// never still be missing payment_option/term_years/lookback_period. If a +// future change to the defaulting logic leaves one blank anyway, this fails +// loud -- naming every still-missing field in one error -- rather than +// letting the request reach Cost Explorer and fail one field at a time (the +// cascade issue #1506 fixes). +func requireSavingsPlansSearchFields(providerType common.ProviderType, args searchRecommendationsArgs) error { + if !isAWSSavingsPlansSearch(providerType, args.Service) { + return nil + } + var missing []string + if args.PaymentOption == "" { + missing = append(missing, "payment_option") + } + if args.TermYears == 0 { + missing = append(missing, "term_years") + } + if args.LookbackPeriod == "" { + missing = append(missing, "lookback_period") + } + if len(missing) == 0 { + return nil + } + return fmt.Errorf("savings plans search missing required field(s): %s", strings.Join(missing, ", ")) +} + // validateSPTypeFilters validates every entry of include/exclude against // the AWS Savings Plans type enum, naming which filter a bad entry came // from. diff --git a/mcp/tools/search_recommendations_test.go b/mcp/tools/search_recommendations_test.go index 0f016d308..ffbae03bb 100644 --- a/mcp/tools/search_recommendations_test.go +++ b/mcp/tools/search_recommendations_test.go @@ -335,3 +335,26 @@ func TestSearchRecommendationsEC2NoDefaultsInjected(t *testing.T) { assert.Empty(t, client.lastParams.PaymentOption) assert.Empty(t, client.lastParams.LookbackPeriod) } + +// TestSearchRecommendationsMultipleInvalidFieldsJoinedInOneError is the +// validate-all-at-once safety net (issue #1506 change 2): a request with +// several invalid fields must name all of them in one returned error instead +// of surfacing only the first one found. +func TestSearchRecommendationsMultipleInvalidFieldsJoinedInOneError(t *testing.T) { + t.Parallel() + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + PaymentOption: "not-a-real-option", + LookbackPeriod: "90d", + IncludeSPTypes: []string{"NotARealType"}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid payment_option") + assert.Contains(t, err.Error(), "invalid lookback_period") + assert.Contains(t, err.Error(), "include_sp_types") +} From d15121937777e7747c1dabff4263c086275c9850 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 26 Jul 2026 01:17:15 +0200 Subject: [PATCH 53/83] docs(mcp): document Savings Plans search defaults Note in the known-gaps section that cudly_search_recommendations defaults term_years/payment_option/lookback_period to 1yr/no-upfront/ 30d for AWS Savings Plans searches, and why reservation searches (EC2/RDS/etc) are unaffected. --- mcp/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/mcp/README.md b/mcp/README.md index 0d06f399d..83ed3a0c0 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -106,6 +106,7 @@ These are pre-existing behaviours in the underlying purchase clients, not someth - **Azure VM Reservations have no partial-upfront billing plan.** Azure honors exactly two billing plans, all-upfront and no-upfront (billed monthly, same total price -- Azure charges no premium for spreading payments), and `cudly_azure_compute_ri_purchase` defaults `payment_option` to no-upfront when omitted. `payment_option=partial-upfront` has no Azure equivalent and is rejected with an explicit error rather than silently purchased under all-upfront or no-upfront instead. - **GCP Compute Engine CUDs commit resources, not instances.** `cudly_gcp_computeengine_cud_purchase` takes `vcpu_count` and `memory_gb` directly (a CUD is a vCPU+memory commitment), not an instance count -- there is no implicit vCPU-per-instance conversion. +- **AWS Savings Plans searches require term/payment/lookback; `cudly_search_recommendations` defaults them.** Unlike EC2/RDS reservation searches (which AWS defaults server-side when `term_years`/`payment_option`/`lookback_period` are omitted), `GetSavingsPlansPurchaseRecommendation` rejects the call unless all three are set. When `service` targets a Savings Plans search on AWS, the tool fills in unset fields with `term_years=1` (1yr), `payment_option=no-upfront`, `lookback_period=30d` -- an explicitly supplied value is never overridden. Reservation searches (EC2/RDS/etc) are unaffected: omitted still means "search all". ## Deployment model From 18c3d0c21a46dc28a2fa5d0e982f5be7e6a338dc Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 26 Jul 2026 01:19:55 +0200 Subject: [PATCH 54/83] fix(aws): filter recommendations by the region search param GetReservationPurchaseRecommendation and GetSavingsPlansPurchaseRecommendation are both account-level Cost Explorer calls with no region parameter -- AWS returns recommendations from every region the account has usage in regardless of what the caller asked for. applyRecommendationFilters already honored include_regions/exclude_regions, but a caller passing only region (as cudly_search_recommendations documents doing) got no region filtering at all, so a us-east-1 search could surface an eu-west-1 recommendation. Fold Region into the include-region set before filtering, matching the existing "in addition to (or instead of) region" semantics include_regions already advertises. No behavior change when no region constraint is supplied. --- mcp/README.md | 1 + providers/aws/service_client.go | 19 +++++++++-- providers/aws/service_client_test.go | 48 ++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 83ed3a0c0..78342949f 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -107,6 +107,7 @@ These are pre-existing behaviours in the underlying purchase clients, not someth - **Azure VM Reservations have no partial-upfront billing plan.** Azure honors exactly two billing plans, all-upfront and no-upfront (billed monthly, same total price -- Azure charges no premium for spreading payments), and `cudly_azure_compute_ri_purchase` defaults `payment_option` to no-upfront when omitted. `payment_option=partial-upfront` has no Azure equivalent and is rejected with an explicit error rather than silently purchased under all-upfront or no-upfront instead. - **GCP Compute Engine CUDs commit resources, not instances.** `cudly_gcp_computeengine_cud_purchase` takes `vcpu_count` and `memory_gb` directly (a CUD is a vCPU+memory commitment), not an instance count -- there is no implicit vCPU-per-instance conversion. - **AWS Savings Plans searches require term/payment/lookback; `cudly_search_recommendations` defaults them.** Unlike EC2/RDS reservation searches (which AWS defaults server-side when `term_years`/`payment_option`/`lookback_period` are omitted), `GetSavingsPlansPurchaseRecommendation` rejects the call unless all three are set. When `service` targets a Savings Plans search on AWS, the tool fills in unset fields with `term_years=1` (1yr), `payment_option=no-upfront`, `lookback_period=30d` -- an explicitly supplied value is never overridden. Reservation searches (EC2/RDS/etc) are unaffected: omitted still means "search all". +- **`region` on `cudly_search_recommendations` is a client-side post-filter for AWS reservation/Savings Plans searches.** `GetReservationPurchaseRecommendation` and `GetSavingsPlansPurchaseRecommendation` are account-level Cost Explorer calls with no region parameter -- AWS returns recommendations from every region the account has usage in. The tool filters the returned recommendations down to `region`/`include_regions` (minus `exclude_regions`) after the call; when no region constraint is supplied, all regions are returned as before. ## Deployment model diff --git a/providers/aws/service_client.go b/providers/aws/service_client.go index 8e5aa1f8d..d8765eb5e 100644 --- a/providers/aws/service_client.go +++ b/providers/aws/service_client.go @@ -85,14 +85,27 @@ func (r *RecommendationsClientAdapter) GetRecommendations(ctx context.Context, p return recs, nil } -// applyRecommendationFilters applies account and region filters to recommendations +// applyRecommendationFilters applies account and region filters to recommendations. +// +// GetReservationPurchaseRecommendation and GetSavingsPlansPurchaseRecommendation +// are both account-level Cost Explorer APIs: neither request carries a region +// parameter, so AWS returns recommendations across every region the account +// has usage in regardless of what params.Region asks for (issue #1506) -- +// this is the only region enforcement a single-region search gets. Region is +// folded into the include-region set here (rather than requiring the caller +// to pass include_regions) so cudly_search_recommendations' region="us-east-1" +// argument -- documented as filtering the search -- actually does. func applyRecommendationFilters(recs []common.Recommendation, params common.RecommendationParams) []common.Recommendation { if len(params.AccountFilter) > 0 { recs = filterByAccounts(recs, params.AccountFilter) } - if len(params.IncludeRegions) > 0 { - recs = filterByIncludedRegions(recs, params.IncludeRegions) + includeRegions := params.IncludeRegions + if params.Region != "" { + includeRegions = append(append([]string{}, includeRegions...), params.Region) + } + if len(includeRegions) > 0 { + recs = filterByIncludedRegions(recs, includeRegions) } if len(params.ExcludeRegions) > 0 { diff --git a/providers/aws/service_client_test.go b/providers/aws/service_client_test.go index ab0f11191..87481f423 100644 --- a/providers/aws/service_client_test.go +++ b/providers/aws/service_client_test.go @@ -290,3 +290,51 @@ func TestRecommendationsClientAdapter_GetAllRecommendations(t *testing.T) { assert.Error(t, err) }) } + +// TestApplyRecommendationFilters_Region is the regression guard for issue +// #1506's Change 3: GetReservationPurchaseRecommendation and +// GetSavingsPlansPurchaseRecommendation are account-level Cost Explorer +// calls with no region parameter, so AWS returns recommendations from every +// region the account has usage in regardless of params.Region. Before the +// fix, only params.IncludeRegions/ExcludeRegions were honored here, so a +// caller passing region alone (as cudly_search_recommendations documents) +// got no region filtering at all -- an eu-west-1 recommendation could +// surface from a us-east-1 search. +func TestApplyRecommendationFilters_Region(t *testing.T) { + recs := []common.Recommendation{ + {Account: "111", Region: "us-east-1"}, + {Account: "222", Region: "eu-west-1"}, + } + + t.Run("region alone filters out other regions", func(t *testing.T) { + got := applyRecommendationFilters(recs, common.RecommendationParams{Region: "us-east-1"}) + require.Len(t, got, 1) + assert.Equal(t, "us-east-1", got[0].Region) + }) + + t.Run("no region constraint returns everything", func(t *testing.T) { + got := applyRecommendationFilters(recs, common.RecommendationParams{}) + assert.Len(t, got, 2) + }) + + t.Run("region and include_regions are additive", func(t *testing.T) { + threeRegionRecs := append(append([]common.Recommendation{}, recs...), common.Recommendation{Account: "333", Region: "ap-southeast-1"}) + got := applyRecommendationFilters(threeRegionRecs, common.RecommendationParams{ + Region: "us-east-1", + IncludeRegions: []string{"eu-west-1"}, + }) + gotRegions := make([]string, len(got)) + for i, r := range got { + gotRegions[i] = r.Region + } + assert.ElementsMatch(t, []string{"us-east-1", "eu-west-1"}, gotRegions) + }) + + t.Run("exclude_regions still applies on top of region", func(t *testing.T) { + got := applyRecommendationFilters(recs, common.RecommendationParams{ + Region: "us-east-1", + ExcludeRegions: []string{"us-east-1"}, + }) + assert.Empty(t, got) + }) +} From 217bf80e1a26132775c0141d5d309316e420c7db Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 26 Jul 2026 01:56:41 +0200 Subject: [PATCH 55/83] fix(aws): stop region filter from dropping all savings plans recs Savings Plans recommendations never populate the top-level Recommendation.Region: account-level plans (Compute/SageMaker/Database) carry no region at all, and EC2Instance plans carry their region in Details.Region instead. filterByIncludedRegions/filterByExcludedRegions matched only the top-level field, so any region or include_regions search silently dropped every Savings Plans recommendation, even when matching ones existed. Add effectiveRegion() to fall back to Details.Region for EC2Instance plans and treat a still-empty region as region-agnostic (always kept by an include filter, never dropped by an exclude filter) rather than "belongs to no region". Also normalize Details.Region at the parser (extractEC2SPFields) so it compares against the same canonical region codes the include/exclude sets use, matching the reservation parsers. --- providers/aws/recommendations/parser_sp.go | 6 +++- providers/aws/service_client.go | 39 +++++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/providers/aws/recommendations/parser_sp.go b/providers/aws/recommendations/parser_sp.go index 3a71a7d75..2cb3663cd 100644 --- a/providers/aws/recommendations/parser_sp.go +++ b/providers/aws/recommendations/parser_sp.go @@ -249,13 +249,17 @@ type ec2SPFields struct { // CE SavingsPlansDetails nested struct for EC2Instance plan recommendations. // Returns a zero-value struct (all empty strings) for non-EC2Instance plan // types and when SavingsPlansDetails is nil, so callers do not need a nil guard. +// Region is normalized (see normalizeRegionName) to match the reservation +// parsers in parser_services.go, so downstream region filtering (see +// service_client.go's filterByIncludedRegions) compares against the same +// canonical region codes rather than occasionally raw CE display names. func extractEC2SPFields(planType types.SupportedSavingsPlansType, detail *types.SavingsPlansPurchaseRecommendationDetail) ec2SPFields { if planType != types.SupportedSavingsPlansTypeEc2InstanceSp || detail.SavingsPlansDetails == nil { return ec2SPFields{} } return ec2SPFields{ instanceFamily: aws.ToString(detail.SavingsPlansDetails.InstanceFamily), - region: aws.ToString(detail.SavingsPlansDetails.Region), + region: normalizeRegionName(aws.ToString(detail.SavingsPlansDetails.Region)), offeringID: aws.ToString(detail.SavingsPlansDetails.OfferingId), } } diff --git a/providers/aws/service_client.go b/providers/aws/service_client.go index d8765eb5e..9328f4af9 100644 --- a/providers/aws/service_client.go +++ b/providers/aws/service_client.go @@ -132,7 +132,33 @@ func filterByAccounts(recs []common.Recommendation, accounts []string) []common. return filtered } -// filterByIncludedRegions filters recommendations to only included regions +// effectiveRegion returns the region to use for region-filter matching. +// Savings Plans recommendations never populate the top-level rec.Region +// (GetSavingsPlansPurchaseRecommendation is account-level and carries no +// region parameter -- see applyRecommendationFilters); EC2Instance Savings +// Plans instead carry their recommended region in Details.Region +// (SavingsPlanDetails, populated by parser_sp.go's extractEC2SPFields). +// Falling back to it here means a region/include_regions filter matches +// EC2Instance SP recs on their real region instead of dropping them via an +// always-empty top-level Region. Compute/SageMaker/Database SP recs are +// genuinely region-agnostic (Details.Region stays "" for them), so this +// still returns "" for those and callers must treat "" as "keep, do not +// filter" rather than "belongs to no region" (see #1495). +func effectiveRegion(rec common.Recommendation) string { + if rec.Region != "" { + return rec.Region + } + if sp, ok := rec.Details.(*common.SavingsPlanDetails); ok && sp != nil { + return sp.Region + } + return "" +} + +// filterByIncludedRegions filters recommendations to only included regions. +// A recommendation whose effective region is empty is region-agnostic +// (account-level Savings Plans recs -- see effectiveRegion) and is always +// kept: an include filter narrows region-scoped recs, it must not silently +// drop recs that carry no region at all. func filterByIncludedRegions(recs []common.Recommendation, regions []string) []common.Recommendation { regionMap := make(map[string]bool) for _, region := range regions { @@ -141,7 +167,8 @@ func filterByIncludedRegions(recs []common.Recommendation, regions []string) []c filtered := make([]common.Recommendation, 0, len(recs)) for _, rec := range recs { - if regionMap[rec.Region] { + effRegion := effectiveRegion(rec) + if effRegion == "" || regionMap[effRegion] { filtered = append(filtered, rec) } } @@ -149,7 +176,10 @@ func filterByIncludedRegions(recs []common.Recommendation, regions []string) []c return filtered } -// filterByExcludedRegions filters out recommendations from excluded regions +// filterByExcludedRegions filters out recommendations from excluded regions. +// A recommendation with an empty effective region (region-agnostic, +// account-level Savings Plans recs -- see effectiveRegion) is never +// excluded: it does not belong to any of the excluded regions. func filterByExcludedRegions(recs []common.Recommendation, regions []string) []common.Recommendation { regionMap := make(map[string]bool) for _, region := range regions { @@ -158,7 +188,8 @@ func filterByExcludedRegions(recs []common.Recommendation, regions []string) []c filtered := make([]common.Recommendation, 0, len(recs)) for _, rec := range recs { - if !regionMap[rec.Region] { + effRegion := effectiveRegion(rec) + if effRegion == "" || !regionMap[effRegion] { filtered = append(filtered, rec) } } From 2f0de35cbb0e5e8eb91db9f90f946d4d992ee929 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 26 Jul 2026 01:57:04 +0200 Subject: [PATCH 56/83] test(aws): cover savings plans recs in region filter tests TestApplyRecommendationFilters_Region only used fixtures with a populated top-level Region, so it could not catch a region filter silently dropping Savings Plans recommendations (which never populate that field). Add TestApplyRecommendationFilters_SavingsPlanRegion with an account-level SP rec (Region and Details.Region both empty) and an EC2Instance-style SP rec (Region empty, Details.Region set), asserting both include and exclude region filters behave correctly for each. --- providers/aws/service_client_test.go | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/providers/aws/service_client_test.go b/providers/aws/service_client_test.go index 87481f423..addf34444 100644 --- a/providers/aws/service_client_test.go +++ b/providers/aws/service_client_test.go @@ -338,3 +338,61 @@ func TestApplyRecommendationFilters_Region(t *testing.T) { assert.Empty(t, got) }) } + +// TestApplyRecommendationFilters_SavingsPlanRegion is the regression guard +// for the read-path bug where a region filter silently dropped ALL Savings +// Plans recommendations. Unlike RI/reservation recs, SP recs never populate +// the top-level rec.Region: account-level plans (Compute/SageMaker/Database) +// carry no region at all, and EC2Instance plans carry their region in +// Details.Region (common.SavingsPlanDetails) instead, per parser_sp.go's +// extractEC2SPFields. Before the fix, filterByIncludedRegions/ +// filterByExcludedRegions matched only rec.Region, so every SP rec -- +// region-agnostic or not -- was silently dropped by any region constraint. +func TestApplyRecommendationFilters_SavingsPlanRegion(t *testing.T) { + accountLevelSP := common.Recommendation{ + Account: "111", + Region: "", + CommitmentType: common.CommitmentSavingsPlan, + Details: &common.SavingsPlanDetails{PlanType: "Compute"}, + } + ec2InstanceSP := common.Recommendation{ + Account: "222", + Region: "", + CommitmentType: common.CommitmentSavingsPlan, + Details: &common.SavingsPlanDetails{PlanType: "EC2Instance", Region: "us-east-1"}, + } + + t.Run("account-level SP rec is kept under a region filter", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{accountLevelSP}, common.RecommendationParams{Region: "us-east-1"}) + require.Len(t, got, 1) + }) + + t.Run("EC2Instance SP rec matching Details.Region is kept", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{ec2InstanceSP}, common.RecommendationParams{Region: "us-east-1"}) + require.Len(t, got, 1) + }) + + t.Run("EC2Instance SP rec not matching Details.Region is dropped", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{ec2InstanceSP}, common.RecommendationParams{Region: "eu-west-1"}) + assert.Empty(t, got) + }) + + t.Run("account-level SP rec is never excluded by exclude_regions", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{accountLevelSP}, common.RecommendationParams{ExcludeRegions: []string{"us-east-1"}}) + require.Len(t, got, 1) + }) + + t.Run("EC2Instance SP rec matching exclude_regions is dropped", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{ec2InstanceSP}, common.RecommendationParams{ExcludeRegions: []string{"us-east-1"}}) + assert.Empty(t, got) + }) + + t.Run("mixed recs: region filter keeps account-level and matching region, drops non-matching", func(t *testing.T) { + regionScoped := common.Recommendation{Account: "333", Region: "eu-west-1"} + got := applyRecommendationFilters( + []common.Recommendation{accountLevelSP, ec2InstanceSP, regionScoped}, + common.RecommendationParams{Region: "us-east-1"}, + ) + require.Len(t, got, 2) + }) +} From abae00fc4061a8e4b533b5d8160c9302d5bf04b6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 14:11:39 +0200 Subject: [PATCH 57/83] fix(mcp): scope purchase idempotency key to the target account The MCP idempotency key was derived only from product dimensions (provider, region, service, resource type, count, term, payment option, Details). Those describe WHAT is bought, never WHERE it lands, so two purchases identical in every product dimension but billed to different accounts derived the same token. On Azure that silently skips a real purchase rather than merely being imprecise. reservations.FindReservationOrderByIdempotencyToken lists reservation orders from the tenant-wide endpoint (ReservationOrdersListURL carries no subscription prefix, and its comment relies on the token being "globally unique per (execution, rec)" -- true for the CLI/web paths whose tokens come from a UUID execution ID, not for the MCP path whose token is purely a function of the request). Buying the same VM reservation for a second subscription in the same tenant therefore matched the first subscription's order by token, short-circuited, and reported success without buying anything for the second subscription. Add PurchaseRequest.CredentialScope, folded into idempotencyKeyFor, and populate it in all nine purchase tools via the new CredentialScope() helper: the caller-supplied override (aws_profile / azure_subscription_id / gcp_project_id) when present, otherwise the ambient environment variable the matching provider factory itself consults. AWS (per-account tag/ClientToken lookups) and GCP (per-project commitment names) scope their own dedupe, so this is defense in depth there and load-bearing on Azure. Regression tests cover the key-level distinction, the end-to-end threading into the token the provider actually dedupes on, and CredentialScope's override/environment/whitespace precedence. --- mcp/tools/aws_ec2_ri.go | 13 ++-- mcp/tools/aws_elasticache_ri.go | 13 ++-- mcp/tools/aws_rds_ri.go | 13 ++-- mcp/tools/aws_savingsplans.go | 13 ++-- mcp/tools/aws_simple_ri.go | 13 ++-- mcp/tools/azure_compute_ri.go | 13 ++-- mcp/tools/gcp_computeengine_cud.go | 13 ++-- mcp/tools/purchase.go | 58 ++++++++++++++-- mcp/tools/purchase_test.go | 108 ++++++++++++++++++++++++++--- 9 files changed, 200 insertions(+), 57 deletions(-) diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go index dce1e6229..62d1e5cf1 100644 --- a/mcp/tools/aws_ec2_ri.go +++ b/mcp/tools/aws_ec2_ri.go @@ -91,12 +91,13 @@ func (t *awsEC2RIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: region, - Recommendation: rec, - DryRun: dryRun, - Confirm: confirm, - ResolveClient: t.resolveClient(args, region), - Nonce: args.IdempotencyNonce, + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args, region), + Nonce: args.IdempotencyNonce, + CredentialScope: CredentialScope(args.AWSProfile, "AWS_PROFILE"), }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/aws_elasticache_ri.go b/mcp/tools/aws_elasticache_ri.go index 46023cfc7..771b9cfe7 100644 --- a/mcp/tools/aws_elasticache_ri.go +++ b/mcp/tools/aws_elasticache_ri.go @@ -83,12 +83,13 @@ func (t *awsElastiCacheRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTo } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: region, - Recommendation: rec, - DryRun: dryRun, - Confirm: confirm, - ResolveClient: t.resolveClient(args, region), - Nonce: args.IdempotencyNonce, + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args, region), + Nonce: args.IdempotencyNonce, + CredentialScope: CredentialScope(args.AWSProfile, "AWS_PROFILE"), }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/aws_rds_ri.go b/mcp/tools/aws_rds_ri.go index 645c9a1b8..c25f24305 100644 --- a/mcp/tools/aws_rds_ri.go +++ b/mcp/tools/aws_rds_ri.go @@ -86,12 +86,13 @@ func (t *awsRDSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: region, - Recommendation: rec, - DryRun: dryRun, - Confirm: confirm, - ResolveClient: t.resolveClient(args, region), - Nonce: args.IdempotencyNonce, + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args, region), + Nonce: args.IdempotencyNonce, + CredentialScope: CredentialScope(args.AWSProfile, "AWS_PROFILE"), }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index dbd09fd81..c79e85302 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -99,12 +99,13 @@ func (t *awsSavingsPlansPurchaseTool) handle(ctx context.Context, _ *mcp.CallToo } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: region, - Recommendation: rec, - DryRun: dryRun, - Confirm: confirm, - ResolveClient: t.resolveClient(args, region, rec.Service), - Nonce: args.IdempotencyNonce, + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args, region, rec.Service), + Nonce: args.IdempotencyNonce, + CredentialScope: CredentialScope(args.AWSProfile, "AWS_PROFILE"), }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index 241baa166..f28a408a7 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -140,12 +140,13 @@ func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReq } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: region, - Recommendation: rec, - DryRun: dryRun, - Confirm: confirm, - ResolveClient: t.resolveClient(args, region), - Nonce: args.IdempotencyNonce, + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args, region), + Nonce: args.IdempotencyNonce, + CredentialScope: CredentialScope(args.AWSProfile, "AWS_PROFILE"), }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index 6f31dfe5c..134e01456 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -100,12 +100,13 @@ func (t *azureComputeRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTool } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: region, - Recommendation: rec, - DryRun: dryRun, - Confirm: confirm, - ResolveClient: t.resolveClient(args, region), - Nonce: args.IdempotencyNonce, + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args, region), + Nonce: args.IdempotencyNonce, + CredentialScope: CredentialScope(args.AzureSubscriptionID, "AZURE_SUBSCRIPTION_ID"), }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index abf78826c..f551ef2b8 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -84,12 +84,13 @@ func (t *gcpComputeEngineCUDPurchaseTool) handle(ctx context.Context, _ *mcp.Cal } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: region, - Recommendation: rec, - DryRun: dryRun, - Confirm: confirm, - ResolveClient: t.resolveClient(args, region), - Nonce: args.IdempotencyNonce, + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args, region), + Nonce: args.IdempotencyNonce, + CredentialScope: CredentialScope(args.GCPProjectID), }) if err != nil { return nil, PurchaseResponse{}, err diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index 2e6b8ccdb..387f4ec1a 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -3,6 +3,7 @@ package tools import ( "context" "fmt" + "os" "strconv" "strings" "time" @@ -64,6 +65,39 @@ type PurchaseRequest struct { // RIs" with the same parameters). When empty (the default), identical // purchases dedupe so retries never double-buy. See idempotencyKeyFor. Nonce string + + // CredentialScope identifies WHERE the purchase lands: the AWS profile, + // Azure subscription, or GCP project the call is billed to. It is folded + // into the idempotency key so two purchases that are identical in every + // product dimension but target different accounts derive DIFFERENT + // tokens. Populated by each tool via CredentialScope(). See + // idempotencyKeyFor for why omitting it is a double-spend/skipped-spend + // hazard on Azure specifically. + CredentialScope string +} + +// CredentialScope resolves the account/subscription/project identifier that +// bounds where a purchase lands: the caller-supplied override when present, +// otherwise the first non-empty value among the ambient environment +// variables the matching provider factory itself consults (e.g. +// AZURE_SUBSCRIPTION_ID, providers/azure/provider.go's +// resolveDefaultSubscription). +// +// It returns "" when neither is set, which is correct rather than an error: +// a provider that resolves its account purely from ambient credentials +// (single visible Azure subscription, AWS STS identity, ambient GCP project) +// is unambiguous for the life of the server process, so there is no second +// scope for a token to collide with. +func CredentialScope(explicit string, envVars ...string) string { + if s := strings.TrimSpace(explicit); s != "" { + return s + } + for _, env := range envVars { + if s := strings.TrimSpace(os.Getenv(env)); s != "" { + return s + } + } + return "" } // PurchaseResponse is the structured result returned to the MCP caller for @@ -143,7 +177,21 @@ func nonZeroCostPtr(v float64) *float64 { // // rec.Account is deliberately excluded: no *FromArgs constructor in this // package populates it today, so folding it in would add an always-empty, -// misleading key component rather than real discrimination. +// misleading key component rather than real discrimination. scope carries +// that information instead -- see below. +// +// scope (the caller's AWS profile / Azure subscription / GCP project, via +// PurchaseRequest.CredentialScope) is folded in because the product +// dimensions above describe WHAT is bought but not WHERE it lands. Omitting +// it is not merely imprecise, it silently skips real purchases on Azure: +// reservations.FindReservationOrderByIdempotencyToken lists reservation +// orders from the TENANT-wide endpoint (no subscription prefix -- see +// ReservationOrdersListURL), so an identical VM reservation requested for a +// second subscription in the same tenant would match the first +// subscription's order by token, short-circuit, and report success without +// buying anything for the second subscription. AWS (per-account tag/ +// ClientToken lookups) and GCP (per-project commitment names) scope their +// own dedupe, so this is defense in depth there and load-bearing on Azure. // // This function is deliberately fail-safe with respect to time: it folds in // no clock reading of any kind. When nonce is empty (the default), two calls @@ -163,9 +211,9 @@ func nonZeroCostPtr(v float64) *float64 { // purchase becomes a distinct one (e.g. "buy 3 now" then "buy 3 more next // week" by passing a fresh nonce on the second call); the same nonce plus // the same dimensions still dedupes a nonce'd retry. -func idempotencyKeyFor(region string, rec common.Recommendation, nonce string) string { - return fmt.Sprintf("mcp:%s:%s:%s:%s:%d:%s:%s:%s:%s", - rec.Provider, region, rec.Service, rec.ResourceType, rec.Count, rec.Term, rec.PaymentOption, +func idempotencyKeyFor(region string, rec common.Recommendation, scope, nonce string) string { + return fmt.Sprintf("mcp:%s:%s:%s:%s:%s:%d:%s:%s:%s:%s", + rec.Provider, scope, region, rec.Service, rec.ResourceType, rec.Count, rec.Term, rec.PaymentOption, detailsKeyComponent(rec.Details), nonce) } @@ -257,7 +305,7 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons return nil, fmt.Errorf("resolve %s service client: %w", rec.Provider, err) } - token := common.DeriveIdempotencyToken(idempotencyKeyFor(req.Region, rec, req.Nonce), 0) + token := common.DeriveIdempotencyToken(idempotencyKeyFor(req.Region, rec, req.CredentialScope, req.Nonce), 0) opts := common.PurchaseOptions{ Source: common.PurchaseSourceMCP, IdempotencyToken: token, diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 282fd5b48..5a860c3a5 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -377,8 +377,8 @@ func TestIdempotencyKeyDistinguishesSavingsPlanHourlyCommitment(t *testing.T) { expensiveRec, _, _, _, err := savingsPlanRecommendationFromArgs(expensiveArgs) require.NoError(t, err) - cheapKey := idempotencyKeyFor(region, cheapRec, "") - expensiveKey := idempotencyKeyFor(region, expensiveRec, "") + cheapKey := idempotencyKeyFor(region, cheapRec, "", "") + expensiveKey := idempotencyKeyFor(region, expensiveRec, "", "") assert.NotEqual(t, cheapKey, expensiveKey, "a $5/hr and a $50/hr Compute Savings Plan must not derive the same idempotency key") } @@ -402,8 +402,8 @@ func TestIdempotencyKeyDistinguishesEC2Platform(t *testing.T) { windowsRec, windowsRegion, _, _, err := ec2RecommendationFromArgs(windowsArgs) require.NoError(t, err) - linuxKey := idempotencyKeyFor(linuxRegion, linuxRec, "") - windowsKey := idempotencyKeyFor(windowsRegion, windowsRec, "") + linuxKey := idempotencyKeyFor(linuxRegion, linuxRec, "", "") + windowsKey := idempotencyKeyFor(windowsRegion, windowsRec, "", "") assert.NotEqual(t, linuxKey, windowsKey, "a Linux and a Windows EC2 RI purchase must not derive the same idempotency key") } @@ -424,8 +424,8 @@ func TestIdempotencyKeySameDimensionsNoNonceAlwaysMatch(t *testing.T) { rec := testRecommendation() region := "us-east-1" - key1 := idempotencyKeyFor(region, rec, "") - key2 := idempotencyKeyFor(region, rec, "") + key1 := idempotencyKeyFor(region, rec, "", "") + key2 := idempotencyKeyFor(region, rec, "", "") assert.Equal(t, key1, key2, "identical dimensions with no nonce must always derive the same key, so a retry never double-buys") } @@ -440,10 +440,10 @@ func TestIdempotencyKeyNonceAuthorizesDistinctRepeat(t *testing.T) { rec := testRecommendation() region := "us-east-1" - noNonceKey := idempotencyKeyFor(region, rec, "") - nonceAKey1 := idempotencyKeyFor(region, rec, "nonce-a") - nonceAKey2 := idempotencyKeyFor(region, rec, "nonce-a") - nonceBKey := idempotencyKeyFor(region, rec, "nonce-b") + noNonceKey := idempotencyKeyFor(region, rec, "", "") + nonceAKey1 := idempotencyKeyFor(region, rec, "", "nonce-a") + nonceAKey2 := idempotencyKeyFor(region, rec, "", "nonce-a") + nonceBKey := idempotencyKeyFor(region, rec, "", "nonce-b") assert.NotEqual(t, noNonceKey, nonceAKey1, "supplying a nonce must authorize a purchase distinct from the no-nonce default") @@ -487,3 +487,91 @@ func TestExecutePurchaseNonceThreadedThroughToToken(t *testing.T) { assert.Equal(t, fake1.lastOpts.IdempotencyToken, fake3.lastOpts.IdempotencyToken, "the same nonce must derive the same idempotency token") } + +// TestIdempotencyKeyDistinguishesCredentialScope is the regression guard for +// the cross-account false-dedupe found in review. The product dimensions +// folded into the key describe WHAT is bought, never WHERE it lands, so two +// identical purchases aimed at different accounts derived the SAME token. +// +// On Azure that silently skips a real purchase rather than merely being +// imprecise: reservations.FindReservationOrderByIdempotencyToken lists +// reservation orders from the TENANT-wide endpoint (ReservationOrdersListURL +// has no subscription prefix), so buying the same VM reservation for a second +// subscription in the same tenant matched the FIRST subscription's order by +// token, short-circuited, and reported success without buying anything for +// the second subscription. This test fails on the pre-fix key, which took no +// scope argument at all. +func TestIdempotencyKeyDistinguishesCredentialScope(t *testing.T) { + t.Parallel() + rec := testRecommendation() + region := "us-east-1" + + subAKey := idempotencyKeyFor(region, rec, "subscription-a", "") + subBKey := idempotencyKeyFor(region, rec, "subscription-b", "") + subAKeyAgain := idempotencyKeyFor(region, rec, "subscription-a", "") + + assert.NotEqual(t, subAKey, subBKey, + "identical purchases billed to different accounts must derive different tokens") + assert.Equal(t, subAKey, subAKeyAgain, + "a retry against the same account must still dedupe") +} + +// TestExecutePurchaseCredentialScopeThreadedThroughToToken proves +// PurchaseRequest.CredentialScope reaches the token the provider actually +// dedupes on, not just idempotencyKeyFor in isolation. +func TestExecutePurchaseCredentialScopeThreadedThroughToToken(t *testing.T) { + t.Parallel() + rec := testRecommendation() + + purchaseInScope := func(scope string) *fakeServiceClient { + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + _, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + CredentialScope: scope, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + require.NoError(t, err) + return fake + } + + subA := purchaseInScope("subscription-a") + subB := purchaseInScope("subscription-b") + subARetry := purchaseInScope("subscription-a") + + assert.NotEqual(t, subA.lastOpts.IdempotencyToken, subB.lastOpts.IdempotencyToken, + "different credential scopes must derive different idempotency tokens") + assert.Equal(t, subA.lastOpts.IdempotencyToken, subARetry.lastOpts.IdempotencyToken, + "the same credential scope must derive the same idempotency token") +} + +// TestCredentialScopeResolution pins CredentialScope's precedence: an +// explicit caller-supplied override always wins, an ambient environment +// variable is the fallback (matching what the provider factory itself +// consults), whitespace-only input counts as absent, and "" is a legitimate +// result rather than an error (see the CredentialScope doc comment). +func TestCredentialScopeResolution(t *testing.T) { + const envVar = "CUDLY_TEST_SUBSCRIPTION_ID" + + t.Run("explicit override wins over the environment", func(t *testing.T) { + t.Setenv(envVar, "from-env") + assert.Equal(t, "explicit", CredentialScope("explicit", envVar)) + }) + + t.Run("falls back to the environment when no override is given", func(t *testing.T) { + t.Setenv(envVar, "from-env") + assert.Equal(t, "from-env", CredentialScope("", envVar)) + }) + + t.Run("whitespace-only values count as absent", func(t *testing.T) { + t.Setenv(envVar, " ") + assert.Empty(t, CredentialScope(" ", envVar)) + }) + + t.Run("surrounding whitespace is trimmed so it cannot fork the key", func(t *testing.T) { + assert.Equal(t, "sub-a", CredentialScope(" sub-a ")) + }) + + t.Run("empty when neither override nor environment is set", func(t *testing.T) { + assert.Empty(t, CredentialScope("", envVar)) + }) +} From 80c2c7951f78c7ba0179cbbeca764e8195db4f14 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 14:13:32 +0200 Subject: [PATCH 58/83] fix(aws): stop region-less reservation recs bypassing the region filter The Savings Plans region fix earlier in this branch exempted a recommendation from both region filters whenever its effective region was empty, so that account-level Compute/SageMaker/Database plans (which carry no region at all) survive a region constraint. That predicate is too broad: an empty region is not by itself proof that a rec is region-agnostic. Every reservation parser in parser_services.go assigns rec.Region only under `if Details.Region != nil`, so an EC2/RDS/ElastiCache/OpenSearch/ Redshift/MemoryDB recommendation whose Cost Explorer payload omitted the region field reaches the filters with Region == "" while still being a single-region purchase. Exempting those let a recommendation of unknown region survive an explicit "us-east-1 only" filter and reach the purchase path, to be bought in whatever region the service client resolved. This filter is shared by the CLI, the web API, and the scheduler, so the blast radius was not limited to the new MCP search tool. Gate the exemption on CommitmentType == CommitmentSavingsPlan, which is what parser_sp.go sets and what "region-agnostic" actually means. A reservation rec with no region is once again dropped by an include filter (its region cannot be shown to match) and kept by an exclude filter (it cannot be shown to be excluded), the conservative direction each filter had before Savings Plans support was added. Also skip blank entries when building either filter's lookup set: a caller-supplied "" matches no real region code, and in the exclude filter it would otherwise drop every region-agnostic rec via a key that was never a region. Regression tests cover both, and the region-less-reservation cases fail against the empty-region-means-agnostic predicate. --- providers/aws/service_client.go | 72 +++++++++++++++++++-------- providers/aws/service_client_test.go | 73 ++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 21 deletions(-) diff --git a/providers/aws/service_client.go b/providers/aws/service_client.go index 9328f4af9..9e2fd2857 100644 --- a/providers/aws/service_client.go +++ b/providers/aws/service_client.go @@ -142,8 +142,8 @@ func filterByAccounts(recs []common.Recommendation, accounts []string) []common. // EC2Instance SP recs on their real region instead of dropping them via an // always-empty top-level Region. Compute/SageMaker/Database SP recs are // genuinely region-agnostic (Details.Region stays "" for them), so this -// still returns "" for those and callers must treat "" as "keep, do not -// filter" rather than "belongs to no region" (see #1495). +// still returns "" for those, and isRegionAgnostic is what decides that such +// a rec is exempt from region filtering (see #1495). func effectiveRegion(rec common.Recommendation) string { if rec.Region != "" { return rec.Region @@ -154,21 +154,55 @@ func effectiveRegion(rec common.Recommendation) string { return "" } -// filterByIncludedRegions filters recommendations to only included regions. -// A recommendation whose effective region is empty is region-agnostic -// (account-level Savings Plans recs -- see effectiveRegion) and is always -// kept: an include filter narrows region-scoped recs, it must not silently -// drop recs that carry no region at all. -func filterByIncludedRegions(recs []common.Recommendation, regions []string) []common.Recommendation { - regionMap := make(map[string]bool) +// isRegionAgnostic reports whether rec legitimately belongs to no single +// region and must therefore be exempt from both region filters: an +// account-level Savings Plan (Compute/SageMaker/Database), which +// GetSavingsPlansPurchaseRecommendation returns without any region because +// the plan applies account-wide. +// +// The CommitmentSavingsPlan check is load-bearing, not decorative. An empty +// effective region is NOT by itself proof that a rec is region-agnostic: +// every reservation parser in parser_services.go writes rec.Region only +// under `if Details.Region != nil`, so an EC2/RDS/ElastiCache/ +// OpenSearch/Redshift/MemoryDB rec whose Cost Explorer payload omitted the +// region field lands here with Region == "" while still being a +// single-region purchase. Treating those as region-agnostic would let a rec +// of unknown region survive an explicit "us-east-1 only" filter and be +// bought in whatever region the service client happens to resolve. A +// reservation rec with no region is dropped by an include filter (its region +// cannot be shown to match) and kept by an exclude filter (it cannot be +// shown to be excluded) -- the same conservative direction each filter had +// before Savings Plans support was added. +func isRegionAgnostic(rec common.Recommendation) bool { + return rec.CommitmentType == common.CommitmentSavingsPlan && effectiveRegion(rec) == "" +} + +// regionSet builds the lookup set for a region filter, skipping blank +// entries. A caller-supplied "" (or a whitespace-only value trimmed to "") +// must never become a matching key: it matches no real region code, and +// without this it would make the exclude filter drop every region-agnostic +// rec via a key that was never a region in the first place. +func regionSet(regions []string) map[string]bool { + set := make(map[string]bool, len(regions)) for _, region := range regions { - regionMap[region] = true + if region == "" { + continue + } + set[region] = true } + return set +} + +// filterByIncludedRegions filters recommendations to only included regions. +// Region-agnostic recommendations (account-level Savings Plans -- see +// isRegionAgnostic) are always kept: an include filter narrows region-scoped +// recs, it must not silently drop recs that belong to no region at all. +func filterByIncludedRegions(recs []common.Recommendation, regions []string) []common.Recommendation { + regionMap := regionSet(regions) filtered := make([]common.Recommendation, 0, len(recs)) for _, rec := range recs { - effRegion := effectiveRegion(rec) - if effRegion == "" || regionMap[effRegion] { + if isRegionAgnostic(rec) || regionMap[effectiveRegion(rec)] { filtered = append(filtered, rec) } } @@ -177,19 +211,15 @@ func filterByIncludedRegions(recs []common.Recommendation, regions []string) []c } // filterByExcludedRegions filters out recommendations from excluded regions. -// A recommendation with an empty effective region (region-agnostic, -// account-level Savings Plans recs -- see effectiveRegion) is never -// excluded: it does not belong to any of the excluded regions. +// Region-agnostic recommendations (account-level Savings Plans -- see +// isRegionAgnostic) are never excluded: they do not belong to any of the +// excluded regions. func filterByExcludedRegions(recs []common.Recommendation, regions []string) []common.Recommendation { - regionMap := make(map[string]bool) - for _, region := range regions { - regionMap[region] = true - } + regionMap := regionSet(regions) filtered := make([]common.Recommendation, 0, len(recs)) for _, rec := range recs { - effRegion := effectiveRegion(rec) - if effRegion == "" || !regionMap[effRegion] { + if isRegionAgnostic(rec) || !regionMap[effectiveRegion(rec)] { filtered = append(filtered, rec) } } diff --git a/providers/aws/service_client_test.go b/providers/aws/service_client_test.go index addf34444..8baa5b1cd 100644 --- a/providers/aws/service_client_test.go +++ b/providers/aws/service_client_test.go @@ -396,3 +396,76 @@ func TestApplyRecommendationFilters_SavingsPlanRegion(t *testing.T) { require.Len(t, got, 2) }) } + +// TestApplyRecommendationFilters_RegionlessReservationNotExempt is the +// regression guard for the over-broad exemption found reviewing #1495. The +// Savings Plans fix above needs region-agnostic recs to survive a region +// filter, but keying that exemption on "effective region is empty" alone +// swept in reservation recs too: every parser in parser_services.go writes +// rec.Region only under `if Details.Region != nil`, so an EC2/RDS/etc +// rec whose Cost Explorer payload omitted the region field carries Region == +// "" while still being a single-region purchase. +// +// Exempting those let a recommendation of unknown region survive an explicit +// "us-east-1 only" filter and reach the purchase path, to be bought in +// whatever region the service client resolved. This test fails on the +// empty-region-means-agnostic version and passes with the +// CommitmentSavingsPlan-gated isRegionAgnostic. +func TestApplyRecommendationFilters_RegionlessReservationNotExempt(t *testing.T) { + regionlessRI := common.Recommendation{ + Account: "111", + Region: "", + CommitmentType: common.CommitmentReservedInstance, + ResourceType: "m5.large", + Details: &common.ComputeDetails{InstanceType: "m5.large"}, + } + + t.Run("region-less reservation rec is dropped by an include filter", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{regionlessRI}, common.RecommendationParams{Region: "us-east-1"}) + assert.Empty(t, got, "a reservation rec of unknown region must not survive an explicit region filter") + }) + + t.Run("region-less reservation rec is dropped by include_regions too", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{regionlessRI}, + common.RecommendationParams{IncludeRegions: []string{"us-east-1", "eu-west-1"}}) + assert.Empty(t, got) + }) + + t.Run("region-less reservation rec survives when no region constraint is set", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{regionlessRI}, common.RecommendationParams{}) + require.Len(t, got, 1, "with no region filter, nothing is dropped") + }) + + t.Run("region-less reservation rec is not excluded by exclude_regions", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{regionlessRI}, + common.RecommendationParams{ExcludeRegions: []string{"us-east-1"}}) + require.Len(t, got, 1, "a rec of unknown region cannot be shown to be in an excluded region") + }) +} + +// TestApplyRecommendationFilters_BlankRegionEntryIsNotAMatcher pins that a +// blank entry in a region filter list is never treated as a matching region +// code. Without regionSet's skip, a blank in ExcludeRegions would put "" in +// the lookup set and drop every account-level Savings Plans rec via a key +// that was never a region. +func TestApplyRecommendationFilters_BlankRegionEntryIsNotAMatcher(t *testing.T) { + accountLevelSP := common.Recommendation{ + Account: "111", + CommitmentType: common.CommitmentSavingsPlan, + Details: &common.SavingsPlanDetails{PlanType: "Compute"}, + } + usEast := common.Recommendation{Account: "222", Region: "us-east-1"} + + t.Run("blank exclude entry does not drop region-agnostic recs", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{accountLevelSP}, + common.RecommendationParams{ExcludeRegions: []string{""}}) + require.Len(t, got, 1) + }) + + t.Run("blank include entry does not match anything", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{usEast}, + common.RecommendationParams{IncludeRegions: []string{"", "us-east-1"}}) + require.Len(t, got, 1) + assert.Equal(t, "us-east-1", got[0].Region) + }) +} From 89cd9adafd815e645655f794e1fed7d6d9ad1139 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 14:16:10 +0200 Subject: [PATCH 59/83] fix(mcp): reject Savings Plans commitments finer than one cent providers/aws/services/savingsplans/client.go renders CreateSavingsPlanInput.Commitment with %.2f, so any hourly_commitment finer than a cent was silently rounded on the way to AWS: a requested $0.004/hour committed to "0.00" and a requested $10.005/hour committed to "10.01". The tool only checked > 0, so both were accepted and spent real money on a figure the caller never asked for -- exactly the silent coercion this money path must not do. Validate at the tool boundary that the caller's amount survives that same %.2f rendering unchanged, and fail loud naming the amount AWS would actually have been asked to commit. The comparison is against the rendered-then- reparsed value with a relative epsilon rather than an exact whole-cents test, because decimal literals like 0.07 are not exactly representable in float64 (0.07*100 is 7.000000000000001) and an exact test would reject most real commitments. This also keeps the idempotency key honest: idempotencyKeyFor folds in the full-precision HourlyCommitment, so two requests AWS would bill identically (10.001 and 10.004, both "10.00") previously derived different tokens and could purchase twice. --- mcp/tools/aws_savingsplans.go | 55 ++++++++++++++++++++++++- mcp/tools/aws_savingsplans_test.go | 64 ++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index c79e85302..f50c22a2b 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -3,6 +3,8 @@ package tools import ( "context" "fmt" + "math" + "strconv" "strings" spTypes "github.com/aws/aws-sdk-go-v2/service/savingsplans/types" @@ -119,8 +121,8 @@ func (t *awsSavingsPlansPurchaseTool) handle(ctx context.Context, _ *mcp.CallToo // function's cyclomatic complexity stays under the repo's gocyclo gate as // validation branches (e.g. validateDatabaseSPConstraints) are added. func validateSavingsPlanArgs(args savingsPlansPurchaseArgs) (spType SPType, term TermYears, paymentOption PaymentOption, err error) { - if args.HourlyCommitment <= 0 { - return "", 0, "", fmt.Errorf("hourly_commitment must be > 0, got %v", args.HourlyCommitment) + if err := validateHourlyCommitment(args.HourlyCommitment); err != nil { + return "", 0, "", err } spType, err = ValidateSPType(args.SPType) if err != nil { @@ -219,6 +221,55 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo return rec, region, dryRun, confirm, nil } +// commitmentFormat is the exact format providers/aws/services/savingsplans/ +// client.go renders CreateSavingsPlanInput.Commitment with. Duplicated here +// deliberately: validateHourlyCommitment's job is to prove the caller's +// amount survives THAT rendering unchanged, so it must compare against the +// same format string rather than an independently-chosen precision. +const commitmentFormat = "%.2f" + +// commitmentEpsilon is the relative tolerance for "the rendered amount is +// the same money as the requested amount". A decimal literal such as 0.07 +// is not exactly representable in float64 (0.07*100 is 7.000000000000001), +// so an exact equality test would reject legitimate whole-cent values; +// anything genuinely finer than a cent differs from its rendering by at +// least ~0.001, orders of magnitude above this bound. +const commitmentEpsilon = 1e-9 + +// validateHourlyCommitment rejects a commitment AWS cannot bill exactly. +// The Savings Plans client renders the amount with %.2f, so anything finer +// than a cent is silently rounded on the way to CreateSavingsPlan: a +// requested $0.004/hour becomes "0.00" and a requested $10.005/hour becomes +// "10.01", in both cases committing real money to a figure the caller never +// asked for. Rounding a money value behind the caller's back is exactly the +// silent coercion this path must not do (feedback_no_silent_fallbacks), so a +// sub-cent value is an explicit error naming the amount AWS would actually +// have charged. +// +// This also keeps the idempotency key honest. idempotencyKeyFor folds in the +// full-precision HourlyCommitment, so without this two requests AWS would +// bill identically (10.001 and 10.004, both "10.00") would derive different +// tokens and could purchase twice. +func validateHourlyCommitment(hourlyCommitment float64) error { + if hourlyCommitment <= 0 { + return fmt.Errorf("hourly_commitment must be > 0, got %v", hourlyCommitment) + } + rendered := fmt.Sprintf(commitmentFormat, hourlyCommitment) + billed, err := strconv.ParseFloat(rendered, 64) + if err != nil { + // Unreachable for a finite float64 rendered with %.2f; a NaN or Inf + // commitment is rejected by the > 0 check above. + return fmt.Errorf("hourly_commitment %v is not a billable amount: %w", hourlyCommitment, err) + } + if math.Abs(hourlyCommitment-billed) > commitmentEpsilon*math.Max(1, hourlyCommitment) { + return fmt.Errorf( + "hourly_commitment %v is finer than one cent: AWS bills a Savings Plan commitment to two decimal "+ + "places, so this would be silently charged as %s/hour instead; pass an amount in whole cents", + hourlyCommitment, rendered) + } + return nil +} + // validateDatabaseSPConstraints rejects a Database Savings Plan request // AWS's purchase API would itself reject: per AWS's Database Savings Plans // announcement (aws.amazon.com/about-aws/whats-new/2025/12/database-savings-plans-savings), diff --git a/mcp/tools/aws_savingsplans_test.go b/mcp/tools/aws_savingsplans_test.go index ed1c05d75..e1264bfb0 100644 --- a/mcp/tools/aws_savingsplans_test.go +++ b/mcp/tools/aws_savingsplans_test.go @@ -426,3 +426,67 @@ func TestAWSSavingsPlansPurchaseHandleRealPurchaseUsesScopedService(t *testing.T assert.Equal(t, common.ServiceSavingsPlansCompute, gotService, "must resolve the plan-type-scoped client, not the umbrella sentinel") assert.Equal(t, common.PurchaseSourceMCP, fake.lastOpts.Source) } + +// TestValidateHourlyCommitmentRejectsSubCentAmounts is the regression guard +// for the silent money-rounding found in review. The Savings Plans client +// renders CreateSavingsPlanInput.Commitment with %.2f +// (providers/aws/services/savingsplans/client.go), so before this check a +// caller asking for $0.004/hour silently committed to "0.00" and one asking +// for $10.005/hour silently committed to "10.01" -- in both cases spending +// real money on a figure they never asked for. +// +// The whole-cent cases matter just as much as the rejections: 0.07 and 0.29 +// are not exactly representable in float64 (0.07*100 is 7.000000000000001), +// so a naive exact-integer-cents test would reject perfectly billable +// amounts and make the tool unusable for most real commitments. +func TestValidateHourlyCommitmentRejectsSubCentAmounts(t *testing.T) { + t.Parallel() + + t.Run("whole-cent amounts are accepted", func(t *testing.T) { + t.Parallel() + for _, v := range []float64{0.01, 0.07, 0.29, 1, 5, 8.11, 10, 10.5, 12.34, 1000.99} { + assert.NoError(t, validateHourlyCommitment(v), "%v is a whole number of cents and must be accepted", v) + } + }) + + t.Run("sub-cent amounts are rejected naming what AWS would have charged", func(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + value float64 + wantBilled string + }{ + {0.004, "0.00"}, + {10.005, "10.01"}, + {1.0 / 3.0, "0.33"}, + {0.001, "0.00"}, + } { + err := validateHourlyCommitment(tc.value) + require.Error(t, err, "%v is finer than a cent and must be rejected", tc.value) + assert.Contains(t, err.Error(), "finer than one cent") + assert.Contains(t, err.Error(), tc.wantBilled, + "the error must name the amount AWS would actually have been asked to commit") + } + }) + + t.Run("non-positive amounts keep their own error", func(t *testing.T) { + t.Parallel() + for _, v := range []float64{0, -5, -0.01} { + err := validateHourlyCommitment(v) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be > 0") + } + }) +} + +// TestSavingsPlanRecommendationFromArgs_RejectsSubCentCommitment pins that +// the sub-cent guard is reachable through the tool's real validation entry +// point, not only when validateHourlyCommitment is called directly. +func TestSavingsPlanRecommendationFromArgs_RejectsSubCentCommitment(t *testing.T) { + t.Parallel() + args := validSavingsPlansArgs() + args.HourlyCommitment = 10.005 + + _, _, _, _, err := savingsPlanRecommendationFromArgs(args) + require.Error(t, err) + assert.Contains(t, err.Error(), "finer than one cent") +} From 5e41a1fbe49b6354f09033c67f3df391e70cff14 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 14:23:13 +0200 Subject: [PATCH 60/83] fix(mcp): reject blank filter entries and trim service in search Two input-hygiene gaps in cudly_search_recommendations, both of which returned a wrong answer instead of an error. A blank entry in include_regions/exclude_regions/account_filter cannot match any real region code or account ID, but a non-empty list still switches the corresponding filter on. include_regions=[" "] therefore activated region filtering with a set that matches nothing and returned zero recommendations, which a caller reads as "your account has nothing worth buying" rather than "your filter was malformed". Reject blank entries by name and index, joined with the other validation errors so a caller sees every problem at once. service was the one identifier field never trimmed, while region, include_regions, exclude_regions and account_filter all were. A padded " savingsplans-compute" both missed the AWS Savings Plans defaulting branch (isAWSSavingsPlansSearch matches on the raw value) and failed the supported-service check for a reason the error text did not explain. Trim it with the others, and move the whole trimming step ahead of validation so every check sees the normalized values the rest of the call actually uses. Extract the per-field validation into collectSearchFieldErrors to keep validateSearchArgs under the pre-commit gocyclo gate of 10. --- mcp/tools/search_recommendations.go | 80 ++++++++++++++++++++++-- mcp/tools/search_recommendations_test.go | 70 +++++++++++++++++++++ 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index 77e6ee47f..740b1f464 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -87,11 +87,15 @@ func (t *searchRecommendationsTool) Register(s *mcp.Server) error { } func (t *searchRecommendationsTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args searchRecommendationsArgs) (*mcp.CallToolResult, searchRecommendationsResult, error) { + // Trim BEFORE validating: validateSearchArgs matches service against the + // Savings Plans family predicate and rejects blank filter-list entries, + // both of which must see the normalized values the rest of the call will + // actually use. + args = trimSearchArgsIdentifiers(args) providerType, term, args, err := validateSearchArgs(args) if err != nil { return nil, searchRecommendationsResult{}, err } - args = trimSearchArgsIdentifiers(args) prov, err := t.createProvider(string(providerType), providerConfigFromArgs(providerType, args)) if err != nil { @@ -134,6 +138,25 @@ func validateSearchArgs(args searchRecommendationsArgs) (common.ProviderType, st } args = applySavingsPlansSearchDefaults(providerType, args) + term, errs := collectSearchFieldErrors(providerType, args) + if len(errs) > 0 { + return "", "", args, errors.Join(errs...) + } + + return providerType, term, args, nil +} + +// collectSearchFieldErrors validates every per-field constraint on args +// (payment option, lookback window, term, Savings Plans type filters, blank +// filter-list entries, and the Savings Plans required-field safety net) and +// returns the normalised Recommendation term alongside EVERY error found, +// rather than stopping at the first. A caller that got several fields wrong +// then fixes them in one round instead of one call at a time. +// +// Split out of validateSearchArgs so that function stays under the repo's +// gocyclo gate (the pre-commit hook is -over 10, stricter than golangci's +// min-complexity 15). +func collectSearchFieldErrors(providerType common.ProviderType, args searchRecommendationsArgs) (string, []error) { var errs []error if args.PaymentOption != "" { @@ -159,15 +182,15 @@ func validateSearchArgs(args searchRecommendationsArgs) (common.ProviderType, st errs = append(errs, err) } - if err := requireSavingsPlansSearchFields(providerType, args); err != nil { + if err := validateFilterListEntries(args); err != nil { errs = append(errs, err) } - if len(errs) > 0 { - return "", "", args, errors.Join(errs...) + if err := requireSavingsPlansSearchFields(providerType, args); err != nil { + errs = append(errs, err) } - return providerType, term, args, nil + return term, errs } // isAWSSavingsPlansSearch reports whether args targets an AWS Savings Plans @@ -254,13 +277,20 @@ func validateSPTypeFilters(include, exclude []string) error { // trimSearchArgsIdentifiers returns args with surrounding whitespace // stripped from every free-text identifier field that flows into -// providerConfigFromArgs/recommendationParamsFromArgs (region, +// providerConfigFromArgs/recommendationParamsFromArgs (service, region, // include_regions, exclude_regions, account_filter). Unlike the purchase // tools' requireNonBlank, region is optional here, so trimming rather than // rejecting a blank/whitespace value is correct: " us-east-1 " must resolve // and search the same region as "us-east-1" instead of silently searching // the wrong (or account-default) region. +// +// This runs before validateSearchArgs so service is already trimmed when +// isAWSSavingsPlansSearch and validateSupportedService match on it: without +// that, " savingsplans-compute" would both miss the Savings Plans defaulting +// branch and fail the supported-service check for a reason (a stray space) +// the error message would not explain. func trimSearchArgsIdentifiers(args searchRecommendationsArgs) searchRecommendationsArgs { + args.Service = strings.TrimSpace(args.Service) args.Region = strings.TrimSpace(args.Region) args.IncludeRegions = trimAll(args.IncludeRegions) args.ExcludeRegions = trimAll(args.ExcludeRegions) @@ -281,6 +311,44 @@ func trimAll(ss []string) []string { return out } +// rejectBlankListEntries fails loud when any entry of a supplied filter list +// is empty or whitespace-only. A blank entry cannot match a real region code +// or account ID, but a non-empty list still switches the corresponding +// filter ON: include_regions=[" "] would activate region filtering with a +// set that matches nothing and silently return zero recommendations, which +// reads as "your account has nothing to buy" rather than "your filter was +// malformed". Naming the offending index makes the malformed entry +// actionable instead of leaving the caller to guess which one it was. +func rejectBlankListEntries(field string, values []string) error { + for i, v := range values { + if v == "" { + return fmt.Errorf("%s[%d] is blank: every entry must be a non-empty identifier", field, i) + } + } + return nil +} + +// validateFilterListEntries runs rejectBlankListEntries over every +// caller-supplied filter list. Split out of validateSearchArgs to keep that +// function under the repo's gocyclo gate (the pre-commit hook is -over 10, +// stricter than golangci's min-complexity 15). +func validateFilterListEntries(args searchRecommendationsArgs) error { + lists := []struct { + field string + values []string + }{ + {"include_regions", args.IncludeRegions}, + {"exclude_regions", args.ExcludeRegions}, + {"account_filter", args.AccountFilter}, + } + for _, list := range lists { + if err := rejectBlankListEntries(list.field, list.values); err != nil { + return err + } + } + return nil +} + // providerConfigFromArgs builds the provider.ProviderConfig for the given // provider from the tool's per-call credential override fields (design §4). func providerConfigFromArgs(providerType common.ProviderType, args searchRecommendationsArgs) *provider.ProviderConfig { diff --git a/mcp/tools/search_recommendations_test.go b/mcp/tools/search_recommendations_test.go index ffbae03bb..1d6bbda92 100644 --- a/mcp/tools/search_recommendations_test.go +++ b/mcp/tools/search_recommendations_test.go @@ -358,3 +358,73 @@ func TestSearchRecommendationsMultipleInvalidFieldsJoinedInOneError(t *testing.T assert.Contains(t, err.Error(), "invalid lookback_period") assert.Contains(t, err.Error(), "include_sp_types") } + +// TestSearchBlankFilterListEntriesRejected is the regression guard for the +// silent empty-result path found in review. A blank entry cannot match any +// real region code or account ID, but a non-empty list still switches the +// corresponding filter ON: include_regions=[" "] activated region +// filtering with a set matching nothing and returned zero recommendations, +// which reads as "your account has nothing worth buying" rather than "your +// filter was malformed". +func TestSearchBlankFilterListEntriesRejected(t *testing.T) { + t.Parallel() + cases := []struct { + name string + mutate func(*searchRecommendationsArgs) + wantSub string + }{ + {"blank include_regions entry", func(a *searchRecommendationsArgs) { + a.IncludeRegions = []string{" "} + }, "include_regions[0] is blank"}, + {"blank exclude_regions entry among valid ones", func(a *searchRecommendationsArgs) { + a.ExcludeRegions = []string{"us-east-1", ""} + }, "exclude_regions[1] is blank"}, + {"blank account_filter entry", func(a *searchRecommendationsArgs) { + a.AccountFilter = []string{"111111111111", "\t"} + }, "account_filter[1] is blank"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + args := searchRecommendationsArgs{Provider: string(common.ProviderAWS), Service: "ec2"} + tc.mutate(&args) + + tool := &searchRecommendationsTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + t.Fatal("provider must not be created for malformed filter input") + return nil, nil + }, + } + _, _, err := tool.handle(context.Background(), nil, args) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantSub) + }) + } +} + +// TestSearchAcceptsWhitespacePaddedService pins that service is trimmed +// before it is matched, like every other identifier field. Untrimmed, a +// " savingsplans-compute" both missed the AWS Savings Plans defaulting +// branch (isAWSSavingsPlansSearch) and failed the supported-service check +// for a reason the error text would not have explained. +func TestSearchAcceptsWhitespacePaddedService(t *testing.T) { + t.Parallel() + args := searchRecommendationsArgs{Provider: string(common.ProviderAWS), Service: " ec2 "} + + recClient := &fakeRecommendationsClient{} + tool := &searchRecommendationsTool{ + createProvider: func(_ string, _ *provider.ProviderConfig) (provider.Provider, error) { + return &fakeProvider{ + name: "aws", + services: []common.ServiceType{common.ServiceEC2}, + recClient: recClient, + }, nil + }, + } + + _, _, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + require.NotNil(t, recClient.lastParams) + assert.Equal(t, common.ServiceEC2, recClient.lastParams.Service, + "service must be trimmed before it reaches the provider") +} From 374c7ad4729993d011d900a06e56dbe290382335 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 14:23:38 +0200 Subject: [PATCH 61/83] fix(mcp): set the GCP CUD payment option instead of leaving it empty cudly_gcp_computeengine_cud_purchase built its Recommendation without a PaymentOption. GCP Compute Engine CUDs have exactly one billing schedule (monthly over the term, no upfront option), so the tool correctly exposes no payment_option argument, but an empty string is not neutral downstream: the offering-details switch in providers/gcp/services/computeengine/client.go has no case for "" and falls through to `default: upfrontCost = totalCost`, reporting the entire commitment as an upfront charge. Set it explicitly to "monthly", the sole value config.ValidPaymentOptionsByProvider["gcp"] recognises, and say why in the tool description so a caller does not go looking for the missing argument. --- mcp/tools/gcp_computeengine_cud.go | 19 ++++++++++++++++++- mcp/tools/gcp_computeengine_cud_test.go | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index f551ef2b8..f425b3103 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -16,7 +16,14 @@ const gcpComputeEngineCUDPurchaseDescription = "Purchase a GCP Compute Engine Co "SPENDS REAL MONEY when dry_run=false and confirm=true. Always call with dry_run=true first (the default) " + "to validate your parameters before committing; a dry_run response never contacts GCP and never spends " + "money. A CUD commits vCPUs and memory directly (not an instance count): vcpu_count is the number of vCPUs " + - "and memory_gb is the amount of memory to commit." + "and memory_gb is the amount of memory to commit. There is no payment_option parameter: GCP Compute Engine " + + "CUDs are billed monthly over the term, with no upfront option." + +// gcpPaymentOption is the only payment schedule GCP Compute Engine CUDs +// offer, and the only value config.ValidPaymentOptionsByProvider["gcp"] +// accepts. Named here rather than written as a bare literal at the one use +// site so the constraint is stated once, next to its rationale. +const gcpPaymentOption = "monthly" // gcpComputeEngineCUDPurchaseArgs is the input schema for // cudly_gcp_computeengine_cud_purchase. memory_gb is required: unlike AWS/ @@ -132,6 +139,16 @@ func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs Count: args.VCPUCount, CommitmentType: common.CommitmentCUD, Term: term.RecommendationTerm(), + // GCP Compute Engine CUDs have exactly one billing schedule: monthly + // over the term, with no upfront option. There is therefore no + // payment_option argument on this tool, but PaymentOption is still + // set explicitly rather than left "", because "" is not neutral + // downstream: providers/gcp/services/computeengine/client.go's + // offering-details switch falls through to `default: upfrontCost = + // totalCost`, reporting the whole commitment as an upfront charge + // for an empty payment option. gcpPaymentOption is the single value + // config.ValidPaymentOptionsByProvider["gcp"] recognises. + PaymentOption: gcpPaymentOption, Details: common.ComputeDetails{ InstanceType: machineType, MemoryGB: args.MemoryGB, diff --git a/mcp/tools/gcp_computeengine_cud_test.go b/mcp/tools/gcp_computeengine_cud_test.go index 9234ce0e5..12a952b18 100644 --- a/mcp/tools/gcp_computeengine_cud_test.go +++ b/mcp/tools/gcp_computeengine_cud_test.go @@ -154,3 +154,19 @@ func TestGCPComputeEngineCUDPurchaseHandleRealPurchase(t *testing.T) { assert.Equal(t, common.ServiceCompute, gotService) assert.Equal(t, common.PurchaseSourceMCP, fake.lastOpts.Source) } + +// TestGCPComputeEngineRecommendationSetsMonthlyPaymentOption pins that the +// CUD recommendation carries an explicit payment option. GCP Compute Engine +// CUDs are monthly-only, so the tool exposes no payment_option argument, but +// leaving Recommendation.PaymentOption empty is not neutral: the offering- +// details switch in providers/gcp/services/computeengine/client.go falls +// through to `default: upfrontCost = totalCost`, reporting the entire +// commitment as an upfront charge for an empty payment option. "monthly" is +// also the sole value config.ValidPaymentOptionsByProvider["gcp"] accepts. +func TestGCPComputeEngineRecommendationSetsMonthlyPaymentOption(t *testing.T) { + t.Parallel() + rec, _, _, _, err := gcpComputeEngineRecommendationFromArgs(validGCPCUDArgs()) + require.NoError(t, err) + assert.Equal(t, "monthly", rec.PaymentOption, + "an empty payment option is read downstream as an upfront charge") +} From b0c00e800690c6eba8d62e84565eeb44a30dbbb6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 14:26:00 +0200 Subject: [PATCH 62/83] feat(mcp): log real purchases and document the missing approval gate A real MCP purchase left no record anywhere. The CLI path emits a common.AuditRecord per purchase (cmd/multi_service.go) and the web path persists a purchase_executions row that also carries the approval history, but the mcp/ tree contained no logging at all, so an operator asking "what did the assistant actually buy?" had nothing to read. Log an ATTEMPT line before the provider call and a matching OK/FAILED line after, recording provider, target account, region, resource, count, term, payment option, commitment ID and a masked idempotency token. Previews are deliberately not logged: they contact no provider and spend nothing, and logging every dry run would bury the real purchases. Output goes to the standard logger (stderr), never stdout, which the MCP stdio transport owns for JSON-RPC framing. Also document in mcp/README.md what this server does not provide, so the gaps are a deliberate choice rather than a surprise: no approval workflow (confirm=true is a guardrail against an accidental call, not an authorization control, and the model supplies it), no persisted audit record in CUDly's own purchase history, and credentials bounded only by what launched the process since the per-call account arguments are chosen by the model. --- mcp/README.md | 9 +++++ mcp/tools/purchase.go | 41 ++++++++++++++++++++ mcp/tools/purchase_test.go | 76 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/mcp/README.md b/mcp/README.md index 78342949f..be56c331c 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -99,6 +99,15 @@ Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_ - 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. - Provider/SDK failures surface their full error text back to the caller; nothing is swallowed. +- Every real purchase writes an `mcp purchase ATTEMPT` line and a matching `mcp purchase OK` / `mcp purchase FAILED` line to **stderr**, recording provider, target account, region, resource, count, term, payment option, the resulting commitment ID, and a masked idempotency token. Dry runs are not logged (they spend nothing). Capture your MCP client's stderr if you want this trail retained. Nothing is written to stdout, which the MCP stdio transport owns for JSON-RPC framing. + +### What this server does NOT give you + +Understand these before enabling real purchases, especially in a shared or production account: + +- **No approval workflow.** The web UI routes a purchase through `purchase_executions` with a scheduled date and, under 4-eyes mode, a second approver who cannot be the creator. This server has no such gate: `dry_run=false` plus `confirm=true` in a single tool call executes immediately. The `confirm` flag is a guardrail against an accidental call, not an authorization control, and the model driving the client supplies it. +- **No persisted audit record.** The CLI writes a `common.AuditRecord` per purchase and the web path persists an execution row; this server writes only the stderr lines above. An MCP purchase does not appear in CUDly's own purchase history, so reconcile against the provider's console/billing data rather than against CUDly. +- **Credentials are whatever launched the process.** `aws_profile` / `azure_subscription_id` / `gcp_project_id` are per-call arguments chosen by the model, so any account reachable from the ambient credentials is reachable from any tool call. Scope the credentials you launch `cudly-mcp` with to what you are willing to let it spend, rather than relying on the tool arguments to constrain it. ## Caveats and known gaps diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index 387f4ec1a..072c22b52 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -3,6 +3,7 @@ package tools import ( "context" "fmt" + "log" "os" "strconv" "strings" @@ -274,6 +275,42 @@ func savingsPlanDetailsKey(d *common.SavingsPlanDetails) string { d.PlanType, d.HourlyCommitment, d.Coverage, d.InstanceFamily, d.Region, d.OfferingID) } +// logPurchaseAttempt and logPurchaseOutcome write the MCP server's audit +// trail for real, money-spending purchases. Without them an MCP purchase +// left no record anywhere: the CLI path emits a common.AuditRecord per +// purchase (cmd/multi_service.go) and the web path persists a +// purchase_executions row that also carries the approval history, but this +// server has neither, so an operator asking "what did the assistant buy?" +// had nothing to read. These lines are that record. +// +// They go to the standard logger, which writes to STDERR. That is load +// bearing: the MCP stdio transport owns stdout for JSON-RPC framing, so +// anything written there would corrupt the protocol stream. +// +// Preview calls are deliberately not logged: they contact no provider and +// spend nothing, and logging every dry run would bury the real purchases in +// the noise they need to stand out from. +// +// The idempotency token is masked (common.MaskToken) rather than written in +// full, matching how every provider client logs it: it is a stable +// per-request identifier, and the prefix is enough to correlate an attempt +// with its outcome. +func logPurchaseAttempt(req PurchaseRequest, rec common.Recommendation, token string) { + log.Printf("mcp purchase ATTEMPT: provider=%s scope=%q region=%s service=%s resource=%s count=%d term=%s payment=%s token=%s", + rec.Provider, req.CredentialScope, req.Region, rec.Service, rec.ResourceType, rec.Count, + rec.Term, rec.PaymentOption, common.MaskToken(token)) +} + +func logPurchaseOutcome(rec common.Recommendation, token, commitmentID string, err error) { + if err != nil { + log.Printf("mcp purchase FAILED: provider=%s resource=%s token=%s: %v", + rec.Provider, rec.ResourceType, common.MaskToken(token), err) + return + } + log.Printf("mcp purchase OK: provider=%s resource=%s count=%d commitment_id=%s token=%s", + rec.Provider, rec.ResourceType, rec.Count, commitmentID, common.MaskToken(token)) +} + // ExecutePurchase runs the shared dry_run/confirm safety gate and, for a // real purchase, resolves the service client and calls PurchaseCommitment // with PurchaseSourceMCP and a derived idempotency token. It never calls @@ -311,12 +348,16 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons IdempotencyToken: token, } + logPurchaseAttempt(req, rec, token) + result, err := client.PurchaseCommitment(ctx, rec, opts) if err != nil { + logPurchaseOutcome(rec, token, "", err) // Full provider error text surfaces to the caller (feedback: // providers must never swallow the underlying SDK/HTTP error). return nil, fmt.Errorf("purchase commitment failed: %w", err) } + logPurchaseOutcome(rec, token, result.CommitmentID, result.Error) resp := &PurchaseResponse{ Success: result.Success, diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 5a860c3a5..1f74c4d4a 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -1,9 +1,11 @@ package tools import ( + "bytes" "context" "encoding/json" "errors" + "log" "testing" "github.com/stretchr/testify/assert" @@ -575,3 +577,77 @@ func TestCredentialScopeResolution(t *testing.T) { assert.Empty(t, CredentialScope("", envVar)) }) } + +// TestExecutePurchaseAuditLogging pins the MCP server's only record of a +// real purchase. The CLI path emits a common.AuditRecord per purchase +// (cmd/multi_service.go) and the web path persists a purchase_executions row +// carrying the approval history; this server has neither, so before these +// log lines an operator asking "what did the assistant actually buy?" had +// nothing at all to read. +// +// It also pins that a preview stays silent (it contacts no provider and +// spends nothing, so logging every dry run would bury the real purchases) +// and that the idempotency token is masked rather than written in full. +func TestExecutePurchaseAuditLogging(t *testing.T) { + // Not parallel: this test swaps the shared standard-logger output. + capture := func(fn func()) string { + var buf bytes.Buffer + prevOut, prevFlags := log.Writer(), log.Flags() + log.SetOutput(&buf) + log.SetFlags(0) + defer func() { + log.SetOutput(prevOut) + log.SetFlags(prevFlags) + }() + fn() + return buf.String() + } + + rec := testRecommendation() + + t.Run("a preview logs nothing", func(t *testing.T) { + out := capture(func() { + _, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: true, + }) + require.NoError(t, err) + }) + assert.Empty(t, out, "a dry run spends nothing and must not pollute the purchase audit trail") + }) + + t.Run("a real purchase logs the attempt and the outcome", func(t *testing.T) { + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "ri-abc123"}} + var out string + out = capture(func() { + _, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + CredentialScope: "subscription-a", + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + require.NoError(t, err) + }) + + assert.Contains(t, out, "mcp purchase ATTEMPT") + assert.Contains(t, out, "mcp purchase OK") + assert.Contains(t, out, "subscription-a", "the audit line must record which account was billed") + assert.Contains(t, out, "ri-abc123", "the audit line must record the resulting commitment ID") + + token := fake.lastOpts.IdempotencyToken + require.NotEmpty(t, token) + assert.NotContains(t, out, token, "the full idempotency token must never be logged") + assert.Contains(t, out, common.MaskToken(token), "the masked token must be logged to correlate attempt with outcome") + }) + + t.Run("a failed purchase logs the failure", func(t *testing.T) { + fake := &fakeServiceClient{purchaseErr: errors.New("insufficient capacity")} + out := capture(func() { + _, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + require.Error(t, err) + }) + assert.Contains(t, out, "mcp purchase FAILED") + assert.Contains(t, out, "insufficient capacity") + }) +} From 9ac4fd32f0f5588bba2a7da0f7dc1c3db7dbaf47 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 14:30:35 +0200 Subject: [PATCH 63/83] fix(azure): diagnose a missing payment option as missing BillingPlanForPaymentOption folded empty into the same error as an unrecognized value, so a purchase that failed because no payment option was supplied was told "partial-upfront has no azure equivalent". That is true and irrelevant, and it sends whoever reads the failure looking in the wrong place. Empty is a reachable state with its own remedy, not a typo: migration 000032 added recommendations.payment_option as TEXT NOT NULL defaulting to the empty string, so a row predating that migration reaches internal/purchase/execution.go with rec.Payment == "" and lands here. Give it a message that names the actual problem and says why a default is not applied for it (Azure's own default is upfront, which would charge the whole commitment immediately). Also correct ReservationOrdersListURL's doc comment. It asserted the idempotency token is "globally unique per (execution, rec)", stated as a property of the token itself; it is really a property the CALLER must provide, satisfied by the CLI/web paths only because they derive the token from a UUID execution ID. Spell out that a caller deriving a token from request parameters must fold in the target subscription, since this lookup is tenant-wide and would otherwise let two subscriptions in one tenant collide. --- .../internal/reservations/purchase.go | 27 ++++++++++++++++-- .../internal/reservations/purchase_test.go | 28 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/providers/azure/services/internal/reservations/purchase.go b/providers/azure/services/internal/reservations/purchase.go index c868761ce..57c22fd6b 100644 --- a/providers/azure/services/internal/reservations/purchase.go +++ b/providers/azure/services/internal/reservations/purchase.go @@ -94,12 +94,25 @@ func ParseTermYears(term string) (int, error) { // cannot express at all) is a hard error rather than a silent default: the // default belongs at the caller (CLI/MCP) layer, never silently applied on // this money-affecting path (feedback_no_silent_fallbacks). +// +// Empty gets its own message rather than sharing the unrecognized-value one. +// It is a reachable state with a different remedy: migration 000032 added +// recommendations.payment_option as TEXT NOT NULL defaulting to the empty +// string, so a row predating that migration reaches +// internal/purchase/execution.go with rec.Payment == "" and lands here. +// Blaming partial-upfront for a value that is simply missing sends whoever +// reads that error looking in the wrong place. func BillingPlanForPaymentOption(paymentOption string) (armreservations.ReservationBillingPlan, error) { switch strings.ToLower(strings.TrimSpace(paymentOption)) { case "all-upfront", "upfront": return armreservations.ReservationBillingPlanUpfront, nil case "no-upfront", "monthly": return armreservations.ReservationBillingPlanMonthly, nil + case "": + return "", fmt.Errorf( + "azure reservations support only upfront or monthly billing, and no payment option was supplied; " + + "set it explicitly (upfront or monthly) rather than relying on a default, because Azure's own " + + "default is upfront and would charge the whole commitment immediately") default: return "", fmt.Errorf( "azure reservations support only upfront or monthly billing; %q is not available (partial-upfront has no azure equivalent)", @@ -128,9 +141,19 @@ func PurchaseURL(reservationOrderID string) string { // ReservationOrdersListURL returns the list-reservation-orders endpoint URL. // The endpoint is tenant-wide (no subscription prefix): the caller's bearer -// token determines visibility, and the idempotency-token tag is globally -// unique per (execution, rec), so a tenant-wide search returns the correct +// token determines visibility, so a tenant-wide search returns the correct // order regardless of which subscription executed the purchase. +// +// That correctness rests entirely on the idempotency token being unique per +// (target subscription, request), and it is the CALLER's job to make it so. +// The CLI/web paths satisfy this by deriving the token from a UUID execution +// ID (common.DeriveIdempotencyToken), which is unique by construction. A +// caller that instead derives a token from the request's own parameters must +// fold the target subscription into it, or two subscriptions in the same +// tenant ordering the same SKU will collide here: the second purchase would +// match the first subscription's order, short-circuit, and report success +// without buying anything. See mcp/tools/purchase.go's idempotencyKeyFor, +// which folds in PurchaseRequest.CredentialScope for exactly this reason. func ReservationOrdersListURL() string { return BaseURL + "/providers/Microsoft.Capacity/reservationOrders?api-version=" + apiVersion } diff --git a/providers/azure/services/internal/reservations/purchase_test.go b/providers/azure/services/internal/reservations/purchase_test.go index e62abd98c..42f50a8ca 100644 --- a/providers/azure/services/internal/reservations/purchase_test.go +++ b/providers/azure/services/internal/reservations/purchase_test.go @@ -748,3 +748,31 @@ func TestBillingPlanForPaymentOption(t *testing.T) { } } } + +// TestBillingPlanForPaymentOption_EmptyHasItsOwnMessage pins that a missing +// payment option is diagnosed as missing, not as an unsupported +// partial-upfront. Empty is a reachable state with a different remedy: the +// recommendations table has carried `payment_option TEXT NOT NULL DEFAULT +// ”` since migration 000032, so a row predating that migration reaches +// internal/purchase/execution.go with rec.Payment == "" and lands here. +// Before this split, the operator reading that failure was told +// partial-upfront had no Azure equivalent, which is true but has nothing to +// do with why their purchase failed. +func TestBillingPlanForPaymentOption_EmptyHasItsOwnMessage(t *testing.T) { + t.Parallel() + + _, emptyErr := BillingPlanForPaymentOption("") + require.Error(t, emptyErr) + assert.Contains(t, emptyErr.Error(), "no payment option was supplied") + assert.NotContains(t, emptyErr.Error(), "partial-upfront", + "a missing value must not be blamed on partial-upfront") + + _, partialErr := BillingPlanForPaymentOption("partial-upfront") + require.Error(t, partialErr) + assert.Contains(t, partialErr.Error(), "partial-upfront has no azure equivalent") + + // Whitespace-only is the same failure as empty, since the switch trims. + _, blankErr := BillingPlanForPaymentOption(" ") + require.Error(t, blankErr) + assert.Contains(t, blankErr.Error(), "no payment option was supplied") +} From 5a1dad097055dc23a9414d018761bf7c77a1e758 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 17:01:38 +0200 Subject: [PATCH 64/83] style(mcp): resolve golangci-lint v2.10.1 findings Three findings from the CI-pinned linter (v2.10.1, matching ci.yml, not the newer local default): a govet shadow on the named err return in validateSavingsPlanArgs, a misspell in the GCP payment-option comment, and a staticcheck S1021 split declaration in the new audit-logging test. The shadow fix uses a distinct variable name rather than `err =`, because plain reassignment trips gocritic's sloppyReassign in the same spot: with a named err return, only a differently-named local satisfies both linters. --- mcp/tools/aws_savingsplans.go | 7 +++++-- mcp/tools/gcp_computeengine_cud.go | 2 +- mcp/tools/purchase_test.go | 3 +-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index f50c22a2b..dd8b4804c 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -121,8 +121,11 @@ func (t *awsSavingsPlansPurchaseTool) handle(ctx context.Context, _ *mcp.CallToo // function's cyclomatic complexity stays under the repo's gocyclo gate as // validation branches (e.g. validateDatabaseSPConstraints) are added. func validateSavingsPlanArgs(args savingsPlansPurchaseArgs) (spType SPType, term TermYears, paymentOption PaymentOption, err error) { - if err := validateHourlyCommitment(args.HourlyCommitment); err != nil { - return "", 0, "", err + // Named commitErr rather than err: this function's err is a named return, + // so `err :=` trips govet's shadow check while `err =` trips gocritic's + // sloppyReassign. A distinct name satisfies both. + if commitErr := validateHourlyCommitment(args.HourlyCommitment); commitErr != nil { + return "", 0, "", commitErr } spType, err = ValidateSPType(args.SPType) if err != nil { diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index f425b3103..d503b495c 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -147,7 +147,7 @@ func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs // offering-details switch falls through to `default: upfrontCost = // totalCost`, reporting the whole commitment as an upfront charge // for an empty payment option. gcpPaymentOption is the single value - // config.ValidPaymentOptionsByProvider["gcp"] recognises. + // config.ValidPaymentOptionsByProvider["gcp"] recognizes. PaymentOption: gcpPaymentOption, Details: common.ComputeDetails{ InstanceType: machineType, diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 1f74c4d4a..a3f187222 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -617,8 +617,7 @@ func TestExecutePurchaseAuditLogging(t *testing.T) { t.Run("a real purchase logs the attempt and the outcome", func(t *testing.T) { fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "ri-abc123"}} - var out string - out = capture(func() { + out := capture(func() { _, err := ExecutePurchase(context.Background(), PurchaseRequest{ Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, CredentialScope: "subscription-a", From 1444ecce71ab89789d11ba05e822cc6c0875c72c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 17:39:36 +0200 Subject: [PATCH 65/83] test(aws): pin blank region skip and savings plans region normalization Addresses two CodeRabbit nitpicks. TestApplyRecommendationFilters_BlankRegionEntryIsNotAMatcher did not pin what it claimed. It used an account-level Savings Plan, which is region-agnostic, so filterByExcludedRegions short-circuits on isRegionAgnostic before the lookup map is ever consulted; the subtest passed with or without regionSet's blank skip. Switch the subject to a region-less reservation, which is NOT region-agnostic but still has an empty effective region, so it reaches the map lookup and is dropped by a "" key when the skip is absent. Verified the strengthened test fails with the skip removed. Add a regression test for extractEC2SPFields' region normalization. Cost Explorer sometimes returns a display name ("US East (N. Virginia)") rather than a region code, and downstream region filters compare against codes like "us-east-1", so an un-normalized value silently drops an EC2Instance Savings Plans recommendation from a search that explicitly asked for its region. --- .../aws/recommendations/parser_sp_test.go | 48 +++++++++++++++++++ providers/aws/service_client_test.go | 37 ++++++++++---- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/providers/aws/recommendations/parser_sp_test.go b/providers/aws/recommendations/parser_sp_test.go index a6a84b903..424c33898 100644 --- a/providers/aws/recommendations/parser_sp_test.go +++ b/providers/aws/recommendations/parser_sp_test.go @@ -455,3 +455,51 @@ func TestRICostParsers_RejectNonFiniteAndNegative(t *testing.T) { }) } } + +// TestExtractEC2SPFieldsNormalizesRegion pins that an EC2Instance Savings +// Plans recommendation's region is canonicalized on the way out of the +// parser. +// +// Cost Explorer sometimes returns a human display name ("US East (N. +// Virginia)") rather than a region code. Downstream, service_client.go's +// region filters compare the value against caller-supplied region codes like +// "us-east-1", so an un-normalized display name matches nothing: an +// EC2Instance SP recommendation would be silently dropped from a search that +// explicitly asked for its region, or wrongly survive an exclude of it. +// The reservation parsers in parser_services.go already normalize; this +// keeps the Savings Plans path consistent with them. +func TestExtractEC2SPFieldsNormalizesRegion(t *testing.T) { + t.Parallel() + + newDetail := func(region string) *types.SavingsPlansPurchaseRecommendationDetail { + return &types.SavingsPlansPurchaseRecommendationDetail{ + SavingsPlansDetails: &types.SavingsPlansDetails{ + InstanceFamily: aws.String("m5"), + Region: aws.String(region), + OfferingId: aws.String("offering-123"), + }, + } + } + + t.Run("display name is canonicalized to a region code", func(t *testing.T) { + t.Parallel() + got := extractEC2SPFields(types.SupportedSavingsPlansTypeEc2InstanceSp, newDetail("US East (N. Virginia)")) + assert.Equal(t, "us-east-1", got.region, + "a Cost Explorer display name must be canonicalized before it reaches region filtering") + assert.Equal(t, "m5", got.instanceFamily) + assert.Equal(t, "offering-123", got.offeringID) + }) + + t.Run("an already-canonical region code passes through unchanged", func(t *testing.T) { + t.Parallel() + got := extractEC2SPFields(types.SupportedSavingsPlansTypeEc2InstanceSp, newDetail("eu-west-1")) + assert.Equal(t, "eu-west-1", got.region) + }) + + t.Run("non-EC2Instance plan types carry no region", func(t *testing.T) { + t.Parallel() + got := extractEC2SPFields(types.SupportedSavingsPlansTypeComputeSp, newDetail("US East (N. Virginia)")) + assert.Empty(t, got.region, "account-level plans are region-agnostic") + assert.Empty(t, got.instanceFamily) + }) +} diff --git a/providers/aws/service_client_test.go b/providers/aws/service_client_test.go index 8baa5b1cd..e3f35f9f4 100644 --- a/providers/aws/service_client_test.go +++ b/providers/aws/service_client_test.go @@ -445,21 +445,40 @@ func TestApplyRecommendationFilters_RegionlessReservationNotExempt(t *testing.T) // TestApplyRecommendationFilters_BlankRegionEntryIsNotAMatcher pins that a // blank entry in a region filter list is never treated as a matching region -// code. Without regionSet's skip, a blank in ExcludeRegions would put "" in -// the lookup set and drop every account-level Savings Plans rec via a key -// that was never a region. +// code. Without regionSet's skip, a blank in ExcludeRegions puts "" in the +// lookup set, and any rec whose effective region is also "" then matches that +// key and is wrongly excluded. +// +// The subject must be a rec that is NOT region-agnostic but still has an +// empty effective region, i.e. a region-less reservation (see +// TestApplyRecommendationFilters_RegionlessReservationNotExempt for why those +// exist). An account-level Savings Plan would NOT pin this: it +// short-circuits on isRegionAgnostic before the map is ever consulted, so +// that subtest would pass with or without the blank skip and prove nothing. func TestApplyRecommendationFilters_BlankRegionEntryIsNotAMatcher(t *testing.T) { - accountLevelSP := common.Recommendation{ + // Not region-agnostic (a reservation), but carries no region because + // Cost Explorer omitted the field. effectiveRegion is "" and + // isRegionAgnostic is false, so this rec reaches the map lookup. + regionlessRI := common.Recommendation{ Account: "111", - CommitmentType: common.CommitmentSavingsPlan, - Details: &common.SavingsPlanDetails{PlanType: "Compute"}, + CommitmentType: common.CommitmentReservedInstance, + ResourceType: "m5.large", + Details: &common.ComputeDetails{InstanceType: "m5.large"}, } usEast := common.Recommendation{Account: "222", Region: "us-east-1"} - t.Run("blank exclude entry does not drop region-agnostic recs", func(t *testing.T) { - got := applyRecommendationFilters([]common.Recommendation{accountLevelSP}, + t.Run("blank exclude entry does not drop a rec with no region", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{regionlessRI}, common.RecommendationParams{ExcludeRegions: []string{""}}) - require.Len(t, got, 1) + require.Len(t, got, 1, + `a blank exclude entry must not become a lookup key that matches an empty effective region`) + }) + + t.Run("blank exclude entry alongside a real one still excludes the real one", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{regionlessRI, usEast}, + common.RecommendationParams{ExcludeRegions: []string{"", "us-east-1"}}) + require.Len(t, got, 1, "us-east-1 is excluded; the region-less rec is not") + assert.Empty(t, got[0].Region) }) t.Run("blank include entry does not match anything", func(t *testing.T) { From b97d14deebe975b560c424fef4e80c6c21bf2ed7 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 17:41:40 +0200 Subject: [PATCH 66/83] refactor(mcp): share one dry_run/confirm default resolver across tools Addresses a CodeRabbit nitpick. All seven purchase tools re-implemented the same block: default dry_run to true, default confirm to false, override each from its *bool when non-nil. EC2 had it as effectiveDryRunConfirm, typed to ec2RIPurchaseArgs so nothing else could reuse it; the other six inlined it. This is the gate that decides whether real money moves, so seven hand-copied versions were seven chances for one to drift. A copy that defaulted dryRun to false would silently turn an unconfirmed preview into a live purchase, and only review would catch it. Replace all seven with ResolveDryRunConfirm(dryRun, confirm *bool), taking the pointers directly so it is not tied to any one tool's args struct. Behavior is unchanged. The table-driven test covers the safety-critical row explicitly: confirm=true with dry_run omitted still resolves to a preview. --- mcp/tools/aws_ec2_ri.go | 17 +-------------- mcp/tools/aws_elasticache_ri.go | 8 +------ mcp/tools/aws_rds_ri.go | 8 +------ mcp/tools/aws_savingsplans.go | 8 +------ mcp/tools/aws_simple_ri.go | 8 +------ mcp/tools/azure_compute_ri.go | 8 +------ mcp/tools/gcp_computeengine_cud.go | 8 +------ mcp/tools/purchase.go | 23 ++++++++++++++++++++ mcp/tools/purchase_test.go | 34 ++++++++++++++++++++++++++++++ 9 files changed, 64 insertions(+), 58 deletions(-) diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go index 62d1e5cf1..e87bf3f44 100644 --- a/mcp/tools/aws_ec2_ri.go +++ b/mcp/tools/aws_ec2_ri.go @@ -143,21 +143,6 @@ func resolveEC2ComputeDimensions(args ec2RIPurchaseArgs) (ec2ComputeDimensions, return dims, nil } -// effectiveDryRunConfirm applies the dry_run=true / confirm=false defaults: -// Go's zero value for bool cannot distinguish "caller omitted the field" -// from "caller explicitly set it false", so both flags are pointers and this -// is the single place that resolves them to concrete booleans. -func effectiveDryRunConfirm(args ec2RIPurchaseArgs) (dryRun, confirm bool) { - dryRun = true - if args.DryRun != nil { - dryRun = *args.DryRun - } - if args.Confirm != nil { - confirm = *args.Confirm - } - return dryRun, confirm -} - // ec2RecommendationFromArgs validates every field of args and builds the // common.Recommendation to purchase, the effective region (trimmed of any // surrounding whitespace), and the effective dry_run/confirm booleans. @@ -203,7 +188,7 @@ func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (rec common.Recommendatio }, } - dryRun, confirm = effectiveDryRunConfirm(args) + dryRun, confirm = ResolveDryRunConfirm(args.DryRun, args.Confirm) return rec, region, dryRun, confirm, nil } diff --git a/mcp/tools/aws_elasticache_ri.go b/mcp/tools/aws_elasticache_ri.go index 771b9cfe7..2271a3d49 100644 --- a/mcp/tools/aws_elasticache_ri.go +++ b/mcp/tools/aws_elasticache_ri.go @@ -140,13 +140,7 @@ func elasticacheRecommendationFromArgs(args elasticacheRIPurchaseArgs) (rec comm }, } - dryRun, confirm = true, false - if args.DryRun != nil { - dryRun = *args.DryRun - } - if args.Confirm != nil { - confirm = *args.Confirm - } + dryRun, confirm = ResolveDryRunConfirm(args.DryRun, args.Confirm) return rec, region, dryRun, confirm, nil } diff --git a/mcp/tools/aws_rds_ri.go b/mcp/tools/aws_rds_ri.go index c25f24305..a29936595 100644 --- a/mcp/tools/aws_rds_ri.go +++ b/mcp/tools/aws_rds_ri.go @@ -148,13 +148,7 @@ func rdsRecommendationFromArgs(args rdsRIPurchaseArgs) (rec common.Recommendatio }, } - dryRun, confirm = true, false - if args.DryRun != nil { - dryRun = *args.DryRun - } - if args.Confirm != nil { - confirm = *args.Confirm - } + dryRun, confirm = ResolveDryRunConfirm(args.DryRun, args.Confirm) return rec, region, dryRun, confirm, nil } diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index dd8b4804c..236a070a3 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -214,13 +214,7 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo Details: details, } - dryRun, confirm = true, false - if args.DryRun != nil { - dryRun = *args.DryRun - } - if args.Confirm != nil { - confirm = *args.Confirm - } + dryRun, confirm = ResolveDryRunConfirm(args.DryRun, args.Confirm) return rec, region, dryRun, confirm, nil } diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index f28a408a7..e12ba82bb 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -189,13 +189,7 @@ func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchas PaymentOption: string(paymentOption), } - dryRun, confirm = true, false - if args.DryRun != nil { - dryRun = *args.DryRun - } - if args.Confirm != nil { - confirm = *args.Confirm - } + dryRun, confirm = ResolveDryRunConfirm(args.DryRun, args.Confirm) return rec, region, dryRun, confirm, nil } diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index 134e01456..3cf87a892 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -157,13 +157,7 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co paymentOption) } - dryRun, confirm = true, false - if args.DryRun != nil { - dryRun = *args.DryRun - } - if args.Confirm != nil { - confirm = *args.Confirm - } + dryRun, confirm = ResolveDryRunConfirm(args.DryRun, args.Confirm) rec = common.Recommendation{ Provider: common.ProviderAzure, diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index d503b495c..44e032111 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -155,13 +155,7 @@ func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs }, } - dryRun, confirm = true, false - if args.DryRun != nil { - dryRun = *args.DryRun - } - if args.Confirm != nil { - confirm = *args.Confirm - } + dryRun, confirm = ResolveDryRunConfirm(args.DryRun, args.Confirm) return rec, region, dryRun, confirm, nil } diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index 072c22b52..566b0e7ac 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -101,6 +101,29 @@ func CredentialScope(explicit string, envVars ...string) string { return "" } +// ResolveDryRunConfirm applies the dry_run=true / confirm=false defaults to a +// tool's optional flags. Go's zero value for bool cannot distinguish "caller +// omitted the field" from "caller explicitly set it false", which is why both +// arrive as *bool; this is the single place that resolves them to concrete +// booleans. +// +// Shared by every purchase tool rather than reimplemented per tool. This is +// the gate that decides whether real money moves, so seven hand-copied +// versions of it were seven chances for one to drift: a copy that defaulted +// dryRun to false would turn an unconfirmed preview into a live purchase, and +// nothing but review would catch it. +func ResolveDryRunConfirm(dryRun, confirm *bool) (effectiveDryRun, effectiveConfirm bool) { + // Absent dry_run means preview. Never the reverse. + effectiveDryRun = true + if dryRun != nil { + effectiveDryRun = *dryRun + } + if confirm != nil { + effectiveConfirm = *confirm + } + return effectiveDryRun, effectiveConfirm +} + // PurchaseResponse is the structured result returned to the MCP caller for // both preview and real-purchase outcomes. Error is a string (not the Go // error) because it crosses the MCP JSON-RPC boundary as tool output, not a diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index a3f187222..2a9b5ecb0 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -650,3 +650,37 @@ func TestExecutePurchaseAuditLogging(t *testing.T) { assert.Contains(t, out, "insufficient capacity") }) } + +// TestResolveDryRunConfirm pins the shared default resolution now used by +// every purchase tool. This is the gate that decides whether real money +// moves, and it was previously hand-copied into seven files; the single +// most important property is that an OMITTED dry_run means preview, never +// execute. +func TestResolveDryRunConfirm(t *testing.T) { + t.Parallel() + ptr := func(b bool) *bool { return &b } + + cases := []struct { + name string + dryRun *bool + confirm *bool + wantDryRun bool + wantConfirm bool + }{ + {"both omitted defaults to preview", nil, nil, true, false}, + // The safety-critical row: confirm=true alone must NOT execute. + // decidePurchaseMode then sees dryRun=true and previews. + {"omitted dry_run stays preview even when confirmed", nil, ptr(true), true, true}, + {"explicit false dry_run is honored", ptr(false), ptr(true), false, true}, + {"explicit true dry_run is honored", ptr(true), ptr(false), true, false}, + {"explicit false confirm is honored", ptr(false), ptr(false), false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotDryRun, gotConfirm := ResolveDryRunConfirm(tc.dryRun, tc.confirm) + assert.Equal(t, tc.wantDryRun, gotDryRun) + assert.Equal(t, tc.wantConfirm, gotConfirm) + }) + } +} From e5f787aed557baa5b4ea73d6e1cf8e89a9fc98c8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 17:49:13 +0200 Subject: [PATCH 67/83] feat(mcp): offer Archera insurance after a purchase completes A completed MCP purchase now returns an `archera` block: the underutilization-insurance pitch, the signup link carrying CUDly attribution, the enrollment window in days, and both partnership disclosures. This mirrors what the CLI already prints after a real purchase (printArcheraPitch) and what the web UI shows in its post-approval modal, so a buyer going through MCP is offered the same coverage as one going through either other surface. Attached only when the purchase actually succeeded: never to a dry run and never to a failed purchase, since neither bought a commitment and neither started an enrollment window. Offering a 7-day window against a purchase that did not happen would be wrong on the facts. Both disclosures ship as first-class response fields rather than prose, because an MCP client renders this payload through a model: sending the signup link without the sponsorship and the works-fine-without-it facts alongside it would let a sponsored recommendation be presented as a neutral one. Tests assert the link never serializes without them. The link, window, disclosures and pitch move to pkg/common so the CLI and the MCP server read one definition instead of each carrying its own copy; cmd/multi_service_stats.go now sources its wording from there. The frontend keeps its TypeScript copy (it cannot import Go) and both sides point at each other. Note for follow-up, deliberately not changed here: internal/email/templates.go sends a different signup link (archera.ai/signup?mode=cudly) than the CLI and frontend (www.archera.ai/cudly). Reconciling them is a partnership/attribution question rather than a refactor, so the divergence is documented next to the constant instead of silently normalized. --- cmd/multi_service_stats.go | 18 +++++---- mcp/README.md | 1 + mcp/tools/purchase.go | 55 ++++++++++++++++++++++++++ mcp/tools/purchase_test.go | 81 ++++++++++++++++++++++++++++++++++++++ pkg/common/archera.go | 50 +++++++++++++++++++++++ 5 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 pkg/common/archera.go diff --git a/cmd/multi_service_stats.go b/cmd/multi_service_stats.go index b98bb77cf..fe7ee024d 100644 --- a/cmd/multi_service_stats.go +++ b/cmd/multi_service_stats.go @@ -169,9 +169,13 @@ func printSuccessRate(success, failed int) { } } -// archeraSignupURL is the Archera signup link with CUDly attribution, shared -// with the web interface (frontend ARCHERA_SIGNUP_URL). -const archeraSignupURL = "https://www.archera.ai/cudly" +// archeraSignupURL is the Archera signup link with CUDly attribution. Now an +// alias for the shared constant in pkg/common, which the MCP server's +// post-purchase offer reads too, so the link and the two disclosures below +// are written once rather than once per binary. Kept identical to the +// frontend's ARCHERA_SIGNUP_URL (frontend/src/archera.ts), which cannot +// import Go. +const archeraSignupURL = common.ArcheraSignupURL // printFinalMessage prints the final message based on mode and results. func printFinalMessage(isDryRun bool, riSuccess int) { @@ -200,11 +204,9 @@ func printArcheraPitch() { AppLogger.Println(" You can buy underutilization insurance for Reserved Instances and") AppLogger.Println(" Savings Plans from Archera by signing up at:") AppLogger.Printf(" %s\n", archeraSignupURL) - AppLogger.Println(" within the first 7 days of the purchase.") - AppLogger.Println("\n This is entirely optional. CUDly's purchase and management features") - AppLogger.Println(" work fully without Archera.") - AppLogger.Println("\n For full disclosure, Archera sponsors CUDly's Open Source development") - AppLogger.Println(" from a fraction of their insurance premiums.") + AppLogger.Printf(" within the first %d days of the purchase.\n", common.ArcheraEnrollmentWindowDays) + AppLogger.Printf("\n %s\n", common.ArcheraNonGatingDisclosure) + AppLogger.Printf("\n %s\n", common.ArcheraSponsorshipDisclosure) } // printSavingsPlansSection prints the Savings Plans summary section. diff --git a/mcp/README.md b/mcp/README.md index be56c331c..30a96f5d6 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -99,6 +99,7 @@ Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_ - 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. - Provider/SDK failures surface their full error text back to the caller; nothing is swallowed. +- A **completed** real purchase carries an `archera` block in the response: an optional underutilization-insurance offer (Archera covers the gap if committed capacity goes unused), the signup link, the enrollment window in days, and both partnership disclosures. It is attached only when the purchase actually succeeded, never to a dry run or a failed purchase, because neither started an enrollment window. Archera sponsors CUDly's development from a fraction of their insurance premiums, and CUDly works fully without it; both facts travel with the link in every response so a client rendering this payload cannot present the offer as a neutral recommendation. - Every real purchase writes an `mcp purchase ATTEMPT` line and a matching `mcp purchase OK` / `mcp purchase FAILED` line to **stderr**, recording provider, target account, region, resource, count, term, payment option, the resulting commitment ID, and a masked idempotency token. Dry runs are not logged (they spend nothing). Capture your MCP client's stderr if you want this trail retained. Nothing is written to stdout, which the MCP stdio transport owns for JSON-RPC framing. ### What this server does NOT give you diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index 566b0e7ac..ea8ccc340 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -151,6 +151,53 @@ type PurchaseResponse struct { EffectiveDate string `json:"effective_date,omitempty"` TermYears int `json:"term_years,omitempty"` Error string `json:"error,omitempty"` + + // Archera is the optional underutilization-insurance offer, populated + // ONLY after a real purchase actually succeeds. See archeraOffer. + Archera *ArcheraOffer `json:"archera,omitempty"` +} + +// ArcheraOffer is the post-purchase Archera underutilization-insurance offer. +// +// Both disclosure fields are non-optional parts of the payload, not +// decoration: CUDly commits to surfacing the sponsorship AND the fact that it +// works fully without Archera everywhere the signup link appears (the CLI's +// printArcheraPitch does the same after a real purchase). An MCP client +// renders this JSON through a model, so shipping the link without the +// disclosures alongside it would let the model present a sponsored +// recommendation as a neutral one. +type ArcheraOffer struct { + // Pitch is what the coverage does and why it is relevant right now. + Pitch string `json:"pitch"` + // SignupURL is the Archera signup link carrying CUDly attribution. + SignupURL string `json:"signup_url"` + // EnrollmentWindowDays is how long from THIS purchase the buyer has to + // enroll it. Surfaced as a number so a client can compute the deadline + // from effective_date rather than parsing it out of prose. + EnrollmentWindowDays int `json:"enrollment_window_days"` + // NonGatingDisclosure states the offer is optional and CUDly works + // without it. + NonGatingDisclosure string `json:"non_gating_disclosure"` + // SponsorshipDisclosure states the financial relationship behind the + // recommendation. + SponsorshipDisclosure string `json:"sponsorship_disclosure"` +} + +// archeraOffer builds the post-purchase Archera offer. +// +// Deliberately NOT attached to a preview: dry_run contacts no provider and +// buys nothing, so there is no commitment to insure and no enrollment window +// running. Deliberately NOT attached to a failed purchase either, for the +// same reason. This mirrors the CLI, which calls printArcheraPitch only under +// `else if riSuccess > 0` (cmd/multi_service_stats.go). +func archeraOffer() *ArcheraOffer { + return &ArcheraOffer{ + Pitch: common.ArcheraPitch, + SignupURL: common.ArcheraSignupURL, + EnrollmentWindowDays: common.ArcheraEnrollmentWindowDays, + NonGatingDisclosure: common.ArcheraNonGatingDisclosure, + SponsorshipDisclosure: common.ArcheraSponsorshipDisclosure, + } } // termYearsFromRecommendationTerm extracts the integer commitment length in @@ -396,5 +443,13 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons if result.Error != nil { resp.Error = result.Error.Error() } + // Offer the insurance only once there is a real commitment to insure and + // the enrollment window has actually started. A provider that reports + // Success=false (or carries an Error) bought nothing, so pitching a + // 7-day window against a purchase that did not happen would be wrong on + // the facts, not merely premature. + if result.Success && result.Error == nil { + resp.Archera = archeraOffer() + } return resp, nil } diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 2a9b5ecb0..20e1e153f 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -684,3 +684,84 @@ func TestResolveDryRunConfirm(t *testing.T) { }) } } + +// TestArcheraOfferOnlyAfterSuccessfulRealPurchase pins when the Archera +// underutilization-insurance offer is attached, and that it always carries +// both partnership disclosures. +// +// The offer must appear ONLY after a real purchase actually succeeds: a dry +// run bought nothing and a failed purchase bought nothing, so in both cases +// there is no commitment to insure and no enrollment window running. This +// mirrors the CLI, which calls printArcheraPitch only under `riSuccess > 0`. +// +// Both disclosures are asserted because CUDly commits to surfacing the +// sponsorship AND the works-fine-without-it fact everywhere the signup link +// appears. An MCP client renders this payload through a model, so a link +// without its disclosures would let a sponsored recommendation be presented +// as a neutral one. +func TestArcheraOfferOnlyAfterSuccessfulRealPurchase(t *testing.T) { + t.Parallel() + rec := testRecommendation() + + t.Run("a dry run carries no offer", func(t *testing.T) { + t.Parallel() + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: true, + }) + require.NoError(t, err) + assert.Nil(t, resp.Archera, "nothing was bought, so there is nothing to insure") + + raw, err := json.Marshal(resp) + require.NoError(t, err) + assert.NotContains(t, string(raw), "archera", + "the offer must be omitted from the payload entirely, not sent empty") + }) + + t.Run("a failed purchase carries no offer", func(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{ + Success: false, + Error: errors.New("insufficient capacity"), + }} + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Nil(t, resp.Archera, "a purchase that did not happen has no enrollment window") + }) + + t.Run("a successful real purchase carries the offer and both disclosures", func(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{ + Success: true, + CommitmentID: "ri-abc123", + }} + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + require.NoError(t, err) + require.NotNil(t, resp.Archera, "a completed purchase must surface the insurance option") + + assert.Equal(t, common.ArcheraSignupURL, resp.Archera.SignupURL) + assert.Equal(t, common.ArcheraEnrollmentWindowDays, resp.Archera.EnrollmentWindowDays) + assert.NotEmpty(t, resp.Archera.Pitch) + + // The two disclosures CUDly commits to keeping visible wherever the + // signup link is shown. + assert.Equal(t, common.ArcheraNonGatingDisclosure, resp.Archera.NonGatingDisclosure) + assert.Equal(t, common.ArcheraSponsorshipDisclosure, resp.Archera.SponsorshipDisclosure) + assert.Contains(t, resp.Archera.NonGatingDisclosure, "work fully without Archera") + assert.Contains(t, resp.Archera.SponsorshipDisclosure, "sponsors") + + // Whatever a client renders, the link never travels without them. + raw, err := json.Marshal(resp) + require.NoError(t, err) + body := string(raw) + require.Contains(t, body, common.ArcheraSignupURL) + assert.Contains(t, body, "non_gating_disclosure") + assert.Contains(t, body, "sponsorship_disclosure") + }) +} diff --git a/pkg/common/archera.go b/pkg/common/archera.go new file mode 100644 index 000000000..64c6d6777 --- /dev/null +++ b/pkg/common/archera.go @@ -0,0 +1,50 @@ +package common + +// Archera partnership surface: the canonical signup link, the enrollment +// window, and the two disclosures CUDly commits to keeping visible EVERYWHERE +// the integration is surfaced. +// +// These live in pkg/common because more than one binary surfaces the offer +// after a purchase completes and each previously carried its own copy of the +// wording: the CLI (cmd/multi_service_stats.go's printArcheraPitch) and now +// the MCP server (mcp/tools/purchase.go). The frontend has its own TypeScript +// copy in frontend/src/archera.ts, which cannot import Go; that one is +// cross-language duplication of the unavoidable kind, and both sides carry a +// comment pointing at the other. +// +// The disclosures are not decoration and must not be dropped from any surface +// that shows the signup link. They are regression-test guarded, because a +// sponsored recommendation presented without stating the sponsorship (or +// without stating that the product works fine without it) misrepresents the +// relationship to the user. + +// ArcheraSignupURL is the Archera signup link carrying CUDly attribution. +// Shared with the CLI and kept identical to the frontend's +// ARCHERA_SIGNUP_URL (frontend/src/archera.ts). +// +// NOTE: internal/email/templates.go currently sends a DIFFERENT link +// (https://archera.ai/signup?mode=cudly). That divergence predates this +// constant and is deliberately left alone here rather than silently +// normalized, because the two may be distinct attribution paths on Archera's +// side; reconciling them is a partnership question, not a refactor. +const ArcheraSignupURL = "https://www.archera.ai/cudly" + +// ArcheraEnrollmentWindowDays is how long after a purchase a buyer has to +// enroll that commitment in Archera's coverage. +const ArcheraEnrollmentWindowDays = 7 + +// ArcheraNonGatingDisclosure states that the offer is optional and that CUDly +// is fully functional without it. Disclosure 1 of 2. +const ArcheraNonGatingDisclosure = "This is entirely optional. CUDly's purchase and management features " + + "work fully without Archera." + +// ArcheraSponsorshipDisclosure states the financial relationship behind the +// recommendation. Disclosure 2 of 2. +const ArcheraSponsorshipDisclosure = "For full disclosure, Archera sponsors CUDly's Open Source development " + + "from a fraction of their insurance premiums." + +// ArcheraPitch is the offer itself: what the coverage does and why a buyer +// who just committed spend might want it. +const ArcheraPitch = "Want to push your coverage to 100% without the risk that a future capacity decrease " + + "leaves you paying for commitments you no longer use? You can buy underutilization insurance for " + + "Reserved Instances and Savings Plans from Archera." From 1579fab146a32b06c3b04050b43f8675d811b770 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 19:06:25 +0200 Subject: [PATCH 68/83] fix(mcp): reject NaN and Inf hourly commitments in Savings Plans validation validateHourlyCommitment's `<= 0` check and %.2f-render-then-reparse trick both let NaN and +Inf slip through uncaught, contradicting the function's own comment that they were rejected. Add an explicit finite check so a non-finite hourly_commitment is refused with a clear error instead of reaching the AWS purchase call. --- mcp/tools/aws_savingsplans.go | 8 ++++++-- mcp/tools/aws_savingsplans_test.go | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index 236a070a3..1ced5c681 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -248,14 +248,18 @@ const commitmentEpsilon = 1e-9 // bill identically (10.001 and 10.004, both "10.00") would derive different // tokens and could purchase twice. func validateHourlyCommitment(hourlyCommitment float64) error { + if math.IsNaN(hourlyCommitment) || math.IsInf(hourlyCommitment, 0) { + return fmt.Errorf("hourly_commitment must be a finite number, got %v", hourlyCommitment) + } if hourlyCommitment <= 0 { return fmt.Errorf("hourly_commitment must be > 0, got %v", hourlyCommitment) } rendered := fmt.Sprintf(commitmentFormat, hourlyCommitment) billed, err := strconv.ParseFloat(rendered, 64) if err != nil { - // Unreachable for a finite float64 rendered with %.2f; a NaN or Inf - // commitment is rejected by the > 0 check above. + // Unreachable: hourlyCommitment is finite here (NaN/Inf rejected + // above), and %.2f on a finite float64 always renders as a plain + // decimal string that strconv.ParseFloat accepts. return fmt.Errorf("hourly_commitment %v is not a billable amount: %w", hourlyCommitment, err) } if math.Abs(hourlyCommitment-billed) > commitmentEpsilon*math.Max(1, hourlyCommitment) { diff --git a/mcp/tools/aws_savingsplans_test.go b/mcp/tools/aws_savingsplans_test.go index e1264bfb0..871c9321b 100644 --- a/mcp/tools/aws_savingsplans_test.go +++ b/mcp/tools/aws_savingsplans_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "math" "testing" "github.com/stretchr/testify/assert" @@ -476,6 +477,21 @@ func TestValidateHourlyCommitmentRejectsSubCentAmounts(t *testing.T) { assert.Contains(t, err.Error(), "must be > 0") } }) + + // NaN and +Inf both fail the "<= 0" check (NaN compares false to + // everything, +Inf compares true to > 0) and %.2f renders them as "NaN" + // and "+Inf" respectively, both of which strconv.ParseFloat happily + // re-parses -- so neither used to hit any rejection path. Pin that they + // are now refused explicitly rather than silently passed through to a + // real AWS purchase call. + t.Run("NaN and Inf are rejected", func(t *testing.T) { + t.Parallel() + for _, v := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + err := validateHourlyCommitment(v) + require.Error(t, err, "%v must be rejected as a non-finite commitment", v) + assert.Contains(t, err.Error(), "finite") + } + }) } // TestSavingsPlanRecommendationFromArgs_RejectsSubCentCommitment pins that From cfb61ec69acfcb509a8821ebeb2672baeb8157ad Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 19:08:44 +0200 Subject: [PATCH 69/83] fix(mcp): log a provider-reported purchase failure as FAILED, not OK logPurchaseOutcome only checked the Go error, so PurchaseResult{Success: false, Error: nil} -- a provider that ran the call but reports it did not actually buy anything -- fell through to the "mcp purchase OK" audit line. Gate the OK line on result.Success explicitly so a failed purchase is never misrecorded as successful in the only audit trail this server keeps. --- mcp/tools/purchase.go | 15 +++++++++++---- mcp/tools/purchase_test.go | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index ea8ccc340..bba5ef376 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -371,8 +371,15 @@ func logPurchaseAttempt(req PurchaseRequest, rec common.Recommendation, token st rec.Term, rec.PaymentOption, common.MaskToken(token)) } -func logPurchaseOutcome(rec common.Recommendation, token, commitmentID string, err error) { - if err != nil { +// logPurchaseOutcome logs FAILED whenever success is false, even if err is +// nil: a provider can report PurchaseResult{Success: false, Error: nil} (no +// Go error, no result.Error, just a plain "did not buy anything"), and that +// case must never be misrecorded as "mcp purchase OK" in the audit trail. +func logPurchaseOutcome(rec common.Recommendation, token, commitmentID string, success bool, err error) { + if !success { + if err == nil { + err = fmt.Errorf("provider reported failure with no error detail") + } log.Printf("mcp purchase FAILED: provider=%s resource=%s token=%s: %v", rec.Provider, rec.ResourceType, common.MaskToken(token), err) return @@ -422,12 +429,12 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons result, err := client.PurchaseCommitment(ctx, rec, opts) if err != nil { - logPurchaseOutcome(rec, token, "", err) + logPurchaseOutcome(rec, token, "", false, err) // Full provider error text surfaces to the caller (feedback: // providers must never swallow the underlying SDK/HTTP error). return nil, fmt.Errorf("purchase commitment failed: %w", err) } - logPurchaseOutcome(rec, token, result.CommitmentID, result.Error) + logPurchaseOutcome(rec, token, result.CommitmentID, result.Success, result.Error) resp := &PurchaseResponse{ Success: result.Success, diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 20e1e153f..45d80e898 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -649,6 +649,28 @@ func TestExecutePurchaseAuditLogging(t *testing.T) { assert.Contains(t, out, "mcp purchase FAILED") assert.Contains(t, out, "insufficient capacity") }) + + // TestExecutePurchaseAuditLogging/a_provider-reported_failure_is_never_ + // logged_as_OK is the regression guard for the audit-line bug found in + // review: PurchaseCommitment can return a nil Go error alongside + // PurchaseResult{Success: false, Error: nil} (the provider ran the call + // but reports it did not actually buy anything), and before this fix that + // combination fell through to the "mcp purchase OK" line because + // logPurchaseOutcome only inspected the (nil) error, never + // result.Success. + t.Run("a provider-reported failure is never logged as OK", func(t *testing.T) { + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: false, Error: nil}} + out := capture(func() { + _, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + require.NoError(t, err, "a provider-reported failure surfaces via the response, not a Go error") + }) + assert.Contains(t, out, "mcp purchase FAILED", + "Success=false must be logged as a failure even with a nil result.Error") + assert.NotContains(t, out, "mcp purchase OK") + }) } // TestResolveDryRunConfirm pins the shared default resolution now used by From b196d5d8c7a7cdf502307800d7e3aca994608457 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 19:09:57 +0200 Subject: [PATCH 70/83] fix(mcp): add operator opt-in gate before executing real purchases The confirm flag on every purchase tool is model-supplied, not an authorization control: a prompt-injected or hallucinating model with ambient production credentials could execute a real purchase from a single tool call, with nothing on the operator's side to stop it. Add CUDLY_MCP_ENABLE_REAL_PURCHASES, checked at the single chokepoint in ExecutePurchase where a real (non-preview) purchase is about to resolve a provider client. Default off: only an exact "1" or "true" (case-insensitive) enables real purchases; anything else, including unset, refuses with an error naming the flag. Dry runs are unaffected either way. Document the gate in mcp/README.md. --- cmd/cudly-mcp/main_test.go | 6 ++ mcp/README.md | 9 ++- mcp/tools/purchase.go | 44 +++++++++++-- mcp/tools/purchase_test.go | 132 +++++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 7 deletions(-) diff --git a/cmd/cudly-mcp/main_test.go b/cmd/cudly-mcp/main_test.go index 1ec678940..6b7c954dd 100644 --- a/cmd/cudly-mcp/main_test.go +++ b/cmd/cudly-mcp/main_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" cudlymcp "github.com/LeanerCloud/CUDly/mcp" + "github.com/LeanerCloud/CUDly/mcp/tools" ) // isolateFromAmbientAWS points the AWS SDK at deliberately nonexistent @@ -59,6 +60,11 @@ func isolateFromAmbientAWS(t *testing.T) { // named profile before any credential lookup or network call happens. func TestRealPurchasePastProviderRegistration(t *testing.T) { isolateFromAmbientAWS(t) + // This test's whole point is driving a real (non-dry-run) purchase call + // far enough to observe what happens after provider registration, so it + // 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") server, err := cudlymcp.NewServer("test-regression") require.NoError(t, err) diff --git a/mcp/README.md b/mcp/README.md index 30a96f5d6..43167e882 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -2,7 +2,7 @@ `cudly-mcp` exposes CUDly's reserved-capacity search and purchase tools (AWS EC2/RDS/ElastiCache/OpenSearch/Redshift/MemoryDB/Savings Plans, Azure VM Reservations, GCP Compute Engine CUDs) to any MCP client -- Claude Code, Claude Desktop, or another MCP-speaking agent -- as a local process. It is a thin wrapper around the same in-process Go packages (`pkg/provider`, `pkg/common`) the `ri-helper` CLI uses; it never shells out to `ri-helper`, and it is not deployed anywhere (see [Deployment model](#deployment-model)). -Every purchase tool is dry-run by default (`dry_run=true`) and requires an explicit `confirm=true` alongside `dry_run=false` before it spends money. See [Safety model](#safety-model). +Every purchase tool is dry-run by default (`dry_run=true`) and requires an explicit `confirm=true` alongside `dry_run=false` before it spends money. A real purchase additionally requires the operator to have started the server with `CUDLY_MCP_ENABLE_REAL_PURCHASES=1` -- unset by default, so a fresh install cannot spend money until the operator opts in. See [Safety model](#safety-model). ## Install @@ -62,6 +62,8 @@ cudly-mcp The server speaks MCP over stdio and logs diagnostics to stderr; it does not print anything to stdout other than protocol traffic, so it is safe to launch directly from an MCP client's process-spawning config (below) rather than through a wrapper script. +By default, `cudly-mcp` cannot execute real purchases: `dry_run=false, confirm=true` calls are refused until you set `CUDLY_MCP_ENABLE_REAL_PURCHASES=1` in the environment the process launches with. Add it to the `env` block in [Register with an MCP client](#register-with-an-mcp-client) once you are ready to let this server spend money. See [Safety model](#safety-model) for the full rule. + ## Register with an MCP client Add an entry to your client's MCP server config. For Claude Code, this is `~/.claude/mcp.json`. If the client does not inherit your shell's `PATH`, use the absolute path `go install` reported: `$(go env GOBIN)/cudly-mcp` if `GOBIN` is set, otherwise `$(go env GOPATH)/bin/cudly-mcp` (`$(go env GOBIN)` expands to an empty string when `GOBIN` is unset, so that path alone is not a valid binary location): @@ -88,7 +90,7 @@ A typical session searches for a recommendation, previews the purchase, then exe 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`). -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; any other combination either previews or returns an explicit refusal error (see [Safety model](#safety-model)). +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. @@ -96,6 +98,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. - A real purchase requires **both** `dry_run=false` **and** `confirm=true`. `dry_run=false` with `confirm=false` (or vice versa) is refused with a structured error, not silently downgraded to a preview or silently ignored. +- 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. - 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. - Provider/SDK failures surface their full error text back to the caller; nothing is swallowed. @@ -106,7 +109,7 @@ Every other provider's purchase tool (`cudly_aws_savingsplans_purchase`, `cudly_ Understand these before enabling real purchases, especially in a shared or production account: -- **No approval workflow.** The web UI routes a purchase through `purchase_executions` with a scheduled date and, under 4-eyes mode, a second approver who cannot be the creator. This server has no such gate: `dry_run=false` plus `confirm=true` in a single tool call executes immediately. The `confirm` flag is a guardrail against an accidental call, not an authorization control, and the model driving the client supplies it. +- **No scheduled/4-eyes approval workflow.** The web UI routes a purchase through `purchase_executions` with a scheduled date and, under 4-eyes mode, a second approver who cannot be the creator. This server has no such workflow: once `CUDLY_MCP_ENABLE_REAL_PURCHASES=1` is set, `dry_run=false` plus `confirm=true` in a single tool call executes immediately. `confirm` is still a guardrail against an accidental call rather than an authorization control -- it is supplied by the model driving the client, not the operator -- but `CUDLY_MCP_ENABLE_REAL_PURCHASES` is the operator-side authorization control layered underneath it: it must be explicitly enabled before *any* tool call, confirmed or not, can spend money on this server. - **No persisted audit record.** The CLI writes a `common.AuditRecord` per purchase and the web path persists an execution row; this server writes only the stderr lines above. An MCP purchase does not appear in CUDly's own purchase history, so reconcile against the provider's console/billing data rather than against CUDly. - **Credentials are whatever launched the process.** `aws_profile` / `azure_subscription_id` / `gcp_project_id` are per-call arguments chosen by the model, so any account reachable from the ambient credentials is reachable from any tool call. Scope the credentials you launch `cudly-mcp` with to what you are willing to let it spend, rather than relying on the tool arguments to constrain it. diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index bba5ef376..a32bb3da5 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -25,6 +25,29 @@ const ( modeExecute ) +// EnvEnableRealPurchases is the operator-side authorization control gating +// every real (non-preview) purchase this server executes. The model-supplied +// confirm flag (decidePurchaseMode) only proves the model asked to spend +// money; it says nothing about whether the operator running this MCP server +// process wants it able to. mcp/README.md previously documented confirm as +// "a guardrail against an accidental call, not an authorization control", +// which left a prompt-injected or simply hallucinating model, given ambient +// production credentials, able to execute a real purchase from a single tool +// call. This env var closes that gap: it must be explicitly set before +// ExecutePurchase will call into a provider for a real purchase, regardless +// of confirm. See realPurchasesEnabled for the fail-closed matching rule. +const EnvEnableRealPurchases = "CUDLY_MCP_ENABLE_REAL_PURCHASES" + +// realPurchasesEnabled reports the operator's opt-in for EnvEnableRealPurchases. +// Fails closed: only an exact "1" or "true" (case-insensitive, surrounding +// whitespace trimmed) enables real purchases. Unset, empty, "0", "false", or +// any other value disables them -- there is no default-on value, matching +// the owner-decided default-off gate design. +func realPurchasesEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(EnvEnableRealPurchases))) + return v == "1" || v == "true" +} + // decidePurchaseMode applies the safety rail from the design doc (§7): a // real purchase requires confirm=true AND dry_run=false. dry_run=true always // wins and returns a preview, regardless of confirm, so a caller previewing @@ -388,10 +411,14 @@ func logPurchaseOutcome(rec common.Recommendation, token, commitmentID string, s rec.Provider, rec.ResourceType, rec.Count, commitmentID, common.MaskToken(token)) } -// ExecutePurchase runs the shared dry_run/confirm safety gate and, for a -// real purchase, resolves the service client and calls PurchaseCommitment -// with PurchaseSourceMCP and a derived idempotency token. It never calls -// ResolveClient in preview mode, so a preview makes zero provider/SDK calls. +// ExecutePurchase runs the shared dry_run/confirm safety gate, then the +// operator's EnvEnableRealPurchases authorization gate, and for a real +// purchase that clears both, resolves the service client and calls +// PurchaseCommitment with PurchaseSourceMCP and a derived idempotency token. +// It never calls ResolveClient in preview mode, so a preview makes zero +// provider/SDK calls; it also never calls ResolveClient when +// EnvEnableRealPurchases is not set, so a disabled server makes zero +// provider/SDK calls for a would-be real purchase either. func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseResponse, error) { mode, err := decidePurchaseMode(req.DryRun, req.Confirm) if err != nil { @@ -411,6 +438,15 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons }, nil } + // Operator authorization gate: mode == modeExecute here (preview already + // returned above), so this is the single chokepoint every purchase tool + // funnels through before a provider is ever touched. See + // EnvEnableRealPurchases for why confirm alone is not enough. + if !realPurchasesEnabled() { + return nil, fmt.Errorf("real purchases are disabled: set %s=1 to allow the MCP server to execute real purchases", + EnvEnableRealPurchases) + } + if req.ResolveClient == nil { return nil, fmt.Errorf("internal error: no ResolveClient configured for real purchase") } diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 45d80e898..137e1e0b3 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "log" + "os" "testing" "github.com/stretchr/testify/assert" @@ -15,6 +16,24 @@ import ( "github.com/LeanerCloud/CUDly/pkg/provider" ) +// TestMain enables the operator-side real-purchase gate for this package's +// test binary by default. Every other test in this file that drives a +// confirmed real purchase (DryRun: false, Confirm: true) predates +// EnvEnableRealPurchases and asserts on what happens once a purchase is +// actually authorized to run (provider called, token derived, response +// shaped correctly, etc) -- none of that is what TestExecutePurchaseRealPurchaseGate +// exists to cover, so defaulting the gate on here keeps them exercising the +// behavior they were written for instead of universally failing at the gate. +// TestExecutePurchaseRealPurchaseGate is the one test that deliberately +// overrides this default, and it does so non-parallel (see its doc comment) +// so no parallel test in this package ever observes a transient override. +func TestMain(m *testing.M) { + if err := os.Setenv(EnvEnableRealPurchases, "1"); err != nil { + panic(err) + } + os.Exit(m.Run()) +} + // fakeServiceClient is a minimal provider.ServiceClient test double. Only // PurchaseCommitment is exercised by these tests; the rest of the interface // is implemented trivially to satisfy the type. @@ -244,6 +263,119 @@ func TestExecutePurchaseUnconfirmedRealPurchaseRefused(t *testing.T) { assert.Contains(t, err.Error(), "confirm=true") } +// setPurchaseGateEnv sets EnvEnableRealPurchases to value for the calling +// test, restoring whatever the variable held before (present or absent) on +// cleanup. value == "" removes the variable entirely rather than setting it +// to an empty string, since this suite needs to pin the real "operator never +// configured this" default, not merely an empty value that happens to behave +// the same way. Written by hand rather than via t.Setenv because t.Setenv +// cannot represent "the variable was absent". +func setPurchaseGateEnv(t *testing.T, value string) { + t.Helper() + prev, had := os.LookupEnv(EnvEnableRealPurchases) + if value == "" { + require.NoError(t, os.Unsetenv(EnvEnableRealPurchases)) + } else { + require.NoError(t, os.Setenv(EnvEnableRealPurchases, value)) + } + t.Cleanup(func() { + if had { + _ = os.Setenv(EnvEnableRealPurchases, prev) + } else { + _ = os.Unsetenv(EnvEnableRealPurchases) + } + }) +} + +// TestExecutePurchaseRealPurchaseGate is the regression guard for the +// operator authorization gap found in review: before EnvEnableRealPurchases +// existed, a confirmed real purchase (dry_run=false, confirm=true) executed +// immediately no matter what the operator running this MCP server process +// wanted -- the model's own confirm flag was the only thing standing between +// a prompt-injected or simply hallucinating model with ambient production +// credentials and a real purchase. This pins that ExecutePurchase now +// refuses a real purchase, and never resolves a client or calls the +// provider, unless the operator has explicitly set EnvEnableRealPurchases to +// "1" or "true"; and that dry runs are unaffected either way. +// +// Deliberately not parallel: every subtest mutates the process-wide +// environment variable this package's TestMain also sets a default for. +// Go's serial tests all finish before any t.Parallel() test in this binary +// begins, so running this test (and its subtests) serially guarantees no +// parallel test ever observes one of these transient overrides. +func TestExecutePurchaseRealPurchaseGate(t *testing.T) { + rec := testRecommendation() + realPurchaseRequest := func(fake *fakeServiceClient, dryRun bool) PurchaseRequest { + return PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: dryRun, Confirm: true, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + } + } + + t.Run("unset refuses a real purchase and never calls the provider", func(t *testing.T) { + setPurchaseGateEnv(t, "") + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + + resp, err := ExecutePurchase(context.Background(), realPurchaseRequest(fake, false)) + + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), EnvEnableRealPurchases, + "the refusal must name the flag the operator needs to set") + assert.Equal(t, 0, fake.purchaseCalls, "the provider must never be called while the gate is disabled") + }) + + t.Run("\"1\" enables a real purchase", func(t *testing.T) { + setPurchaseGateEnv(t, "1") + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "ri-gate-1"}} + + resp, err := ExecutePurchase(context.Background(), realPurchaseRequest(fake, false)) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.True(t, resp.Success) + assert.Equal(t, 1, fake.purchaseCalls) + }) + + t.Run("\"true\" enables a real purchase regardless of case", func(t *testing.T) { + setPurchaseGateEnv(t, "TrUe") + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + + resp, err := ExecutePurchase(context.Background(), realPurchaseRequest(fake, false)) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, 1, fake.purchaseCalls) + }) + + t.Run("0, false, and garbage all refuse a real purchase", func(t *testing.T) { + for _, v := range []string{"0", "false", "False", "yes", "enabled", " "} { + setPurchaseGateEnv(t, v) + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + + resp, err := ExecutePurchase(context.Background(), realPurchaseRequest(fake, false)) + + require.Errorf(t, err, "%q must not enable real purchases", v) + assert.Nil(t, resp) + assert.Equalf(t, 0, fake.purchaseCalls, "%q must not enable real purchases", v) + } + }) + + t.Run("dry run is unaffected by the gate either way", func(t *testing.T) { + for _, v := range []string{"", "1", "0"} { + setPurchaseGateEnv(t, v) + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + + resp, err := ExecutePurchase(context.Background(), realPurchaseRequest(fake, true)) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.True(t, resp.DryRun) + assert.Equal(t, 0, fake.purchaseCalls, "a preview must never call the provider regardless of the gate") + } + }) +} + // TestExecutePurchaseRealPurchaseCallsProviderWithMCPSource proves a // confirmed real purchase resolves the client, calls PurchaseCommitment // exactly once, and stamps PurchaseSourceMCP + a non-empty idempotency From dd9de1c1d38195929240974661e3399d9fdac190 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 19:19:22 +0200 Subject: [PATCH 71/83] fix(aws): route parser warnings to stderr, never stdout providers/aws/recommendations is linked into cmd/cudly-mcp, and the MCP stdio transport owns stdout for JSON-RPC framing. Two parser warnings used fmt.Printf, so they went to stdout: parser_ri.go parseRecommendations, per unparseable recommendation detail parser_sp.go the multi-plan-type path, per plan type whose CE call fails Either one fires during a cudly_search_recommendations call and injects a bare line of prose into the middle of the JSON-RPC stream, corrupting the protocol and breaking the client session. The SP path is not an exotic case: it triggers whenever one plan type fails while others succeed, e.g. Database SP being unavailable in an account. Both now use log.Printf, which writes to stderr like every other warning in these files. TestParseRecommendations_SkipsInvalidDetails already drove the RI path but only asserted the returned count, so it stayed green the whole time the bug was live. The new TestParseRecommendations_WarningsNeverGoToStdout asserts the property that matters by swapping os.Stdout for a pipe: verified failing before this change and passing after. --- providers/aws/recommendations/parser_ri.go | 7 +- .../aws/recommendations/parser_ri_test.go | 108 ++++++++++++++++++ providers/aws/recommendations/parser_sp.go | 7 +- 3 files changed, 120 insertions(+), 2 deletions(-) diff --git a/providers/aws/recommendations/parser_ri.go b/providers/aws/recommendations/parser_ri.go index a8d7ff3d4..790c96111 100644 --- a/providers/aws/recommendations/parser_ri.go +++ b/providers/aws/recommendations/parser_ri.go @@ -22,7 +22,12 @@ func (c *Client) parseRecommendations(ctx context.Context, awsRecs []types.Reser for i, details := range awsRec.RecommendationDetails { rec, err := c.parseRecommendationDetail(ctx, &details, params) if err != nil { - fmt.Printf("Warning: Failed to parse recommendation detail %d: %v\n", i, err) + // log (stderr), never fmt.Print (stdout): this package is + // linked into cmd/cudly-mcp, whose stdio transport owns + // stdout for JSON-RPC framing. A warning printed here during + // a cudly_search_recommendations call would be interleaved + // into the protocol stream and corrupt the session. + log.Printf("Warning: Failed to parse recommendation detail %d: %v", i, err) continue } diff --git a/providers/aws/recommendations/parser_ri_test.go b/providers/aws/recommendations/parser_ri_test.go index 3ab72efff..2e34a14da 100644 --- a/providers/aws/recommendations/parser_ri_test.go +++ b/providers/aws/recommendations/parser_ri_test.go @@ -1,7 +1,11 @@ package recommendations import ( + "bytes" "context" + "io" + "log" + "os" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -596,3 +600,107 @@ func TestParseRIUtilizationSignals(t *testing.T) { }) } } + +// TestParseRecommendations_WarningsNeverGoToStdout is the regression guard for +// the MCP stdio-protocol corruption found in review of #1495. +// +// This package is linked into cmd/cudly-mcp (via providers/aws), and the MCP +// stdio transport owns STDOUT for JSON-RPC framing. parseRecommendations logs +// a warning for every recommendation detail it cannot parse; while that +// warning used fmt.Printf it went to stdout, so a single unparseable detail +// during a cudly_search_recommendations call injected a bare line of prose +// into the middle of the JSON-RPC stream and broke the client session. +// +// TestParseRecommendations_SkipsInvalidDetails above already drives this exact +// code path, but it only asserts the returned recommendation count -- it stayed +// green the entire time the bug was live. This test asserts the property that +// actually matters: nothing reaches stdout, whatever is logged. +func TestParseRecommendations_WarningsNeverGoToStdout(t *testing.T) { + // Not parallel: swaps the process-wide os.Stdout and the shared logger. + client := &Client{} + + awsRecs := []types.ReservationPurchaseRecommendation{ + { + RecommendationDetails: []types.ReservationPurchaseRecommendationDetail{ + { + // Invalid: missing quantity, so parseRecommendationDetail + // fails and parseRecommendations logs its warning. + RecommendedNumberOfInstancesToPurchase: nil, + EstimatedMonthlySavingsAmount: aws.String("50.00"), + InstanceDetails: &types.InstanceDetails{ + EC2InstanceDetails: &types.EC2InstanceDetails{ + InstanceType: aws.String("m5.large"), + }, + }, + }, + }, + }, + } + params := common.RecommendationParams{ + Service: common.ServiceEC2, + PaymentOption: "partial-upfront", + Term: "1yr", + LookbackPeriod: "7d", + } + + stdout, logged := captureStdoutAndLog(t, func() { + recs, err := client.parseRecommendations(context.Background(), awsRecs, params) + require.NoError(t, err) + assert.Empty(t, recs, "the single invalid detail must be skipped") + }) + + assert.Empty(t, stdout, + "nothing may be written to stdout: cmd/cudly-mcp frames JSON-RPC there and any stray byte corrupts the protocol") + assert.Contains(t, logged, "Failed to parse recommendation detail", + "the warning must still be emitted, just on stderr via log rather than stdout") +} + +// captureStdoutAndLog runs fn with os.Stdout redirected to a pipe and the +// standard logger redirected to a buffer, returning whatever fn wrote to each. +// Both are restored before it returns. +// +// os.Stdout is swapped (rather than only the logger) because the bug under +// test wrote via fmt.Printf, which resolves os.Stdout at call time and so is +// invisible to any logger-only capture. +func captureStdoutAndLog(t *testing.T, fn func()) (stdout, logged string) { + t.Helper() + + r, w, err := os.Pipe() + require.NoError(t, err) + + prevStdout := os.Stdout + prevOut, prevFlags := log.Writer(), log.Flags() + var logBuf bytes.Buffer + os.Stdout = w + log.SetOutput(&logBuf) + log.SetFlags(0) + + // Drain the pipe concurrently so a write larger than the pipe buffer + // cannot deadlock fn. + drained := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, copyErr := io.Copy(&buf, r) + if copyErr != nil { + // Surfaced via the returned string rather than require.* -- + // this runs on a non-test goroutine, where require.* is unsafe + // (feedback_require_in_goroutines). + buf.WriteString("") + } + drained <- buf.String() + }() + + func() { + defer func() { + os.Stdout = prevStdout + log.SetOutput(prevOut) + log.SetFlags(prevFlags) + require.NoError(t, w.Close()) + }() + fn() + }() + + stdout = <-drained + require.NoError(t, r.Close()) + return stdout, logBuf.String() +} diff --git a/providers/aws/recommendations/parser_sp.go b/providers/aws/recommendations/parser_sp.go index 2cb3663cd..f51a88230 100644 --- a/providers/aws/recommendations/parser_sp.go +++ b/providers/aws/recommendations/parser_sp.go @@ -70,7 +70,12 @@ func (c *Client) getSavingsPlansRecommendations(ctx context.Context, params *com if len(planTypes) == 1 { return nil, fmt.Errorf("failed to get %s recommendations: %w", planType, err) } - fmt.Printf("Warning: Failed to get %s recommendations: %v\n", planType, err) + // log (stderr), never fmt.Print (stdout): see the identical note + // in parser_ri.go's parseRecommendations. cmd/cudly-mcp frames + // JSON-RPC on stdout, and this branch fires whenever ONE plan + // type fails while others succeed (e.g. Database SP unavailable + // in an account) -- a routine condition, not a rare one. + log.Printf("Warning: Failed to get %s recommendations: %v", planType, err) continue } From de27120f4c5da5c448b9c13398d3bdeb7e55f01b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 19:35:06 +0200 Subject: [PATCH 72/83] fix(aws): keep region-scoped EC2Instance SPs under region filters isRegionAgnostic exempted any CommitmentSavingsPlan with an empty effective region from both region filters. Only the account-level plan types (Compute, SageMaker, Database) belong to no region. An EC2Instance Savings Plan is region-scoped, and extractEC2SPFields yields Region == "" whenever Cost Explorer omitted SavingsPlansDetails or its Region field, since aws.ToString maps a nil pointer to "". So a region-scoped EC2Instance SP of unknown region survived an explicit "us-east-1 only" filter and could be purchased in whatever region the service client resolved. That is the same defect the CommitmentSavingsPlan gate just closed for reservations, one plan type over, and the function's own doc comment already described the intended rule as Compute/SageMaker/Database. The exemption now requires positive evidence that the plan is account-level: Details must be *SavingsPlanDetails and its PlanType must be recognised. Anything unknown (nil Details, a non-SavingsPlanDetails payload, an unrecognised plan type) stays region-scoped and is filtered conservatively, matching the direction the reservation case already took. Plan types are compared against the SDK's own sptypes.SavingsPlanType members rather than bare literals, so this vocabulary cannot drift from spPlanTypeDisplayString's. TestApplyRecommendationFilters_RegionlessEC2InstanceSPNotExempt covers the region-less EC2Instance SP under both filter shapes, the unrecognised and missing-Details cases, that account-level plans stay exempt, and that an EC2Instance SP carrying a real region is still matched on it. Verified failing on the CommitmentSavingsPlan-only gate and passing after. --- providers/aws/service_client.go | 43 ++++++++++++- providers/aws/service_client_test.go | 93 ++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/providers/aws/service_client.go b/providers/aws/service_client.go index 9e2fd2857..3804b1223 100644 --- a/providers/aws/service_client.go +++ b/providers/aws/service_client.go @@ -173,8 +173,49 @@ func effectiveRegion(rec common.Recommendation) string { // cannot be shown to match) and kept by an exclude filter (it cannot be // shown to be excluded) -- the same conservative direction each filter had // before Savings Plans support was added. +// +// CommitmentSavingsPlan alone is likewise not enough. Only the account-level +// plan types (Compute, SageMaker, Database) belong to no region; an +// EC2Instance Savings Plan is region-SCOPED, and extractEC2SPFields +// (recommendations/parser_sp.go) yields Region == "" whenever Cost Explorer +// omitted SavingsPlansDetails or its Region field, because aws.ToString maps +// a nil pointer to "". Exempting those would reopen exactly the hole the +// paragraph above closes for reservations, just for EC2Instance SPs. So the +// exemption requires POSITIVE evidence that the plan is account-level: +// isAccountLevelSPPlanType must recognize the plan type, and anything +// unknown (nil Details, a non-SavingsPlanDetails payload, a plan type this +// build has never heard of) stays region-scoped and is filtered +// conservatively rather than exempted. func isRegionAgnostic(rec common.Recommendation) bool { - return rec.CommitmentType == common.CommitmentSavingsPlan && effectiveRegion(rec) == "" + if rec.CommitmentType != common.CommitmentSavingsPlan || effectiveRegion(rec) != "" { + return false + } + sp, ok := rec.Details.(*common.SavingsPlanDetails) + return ok && sp != nil && isAccountLevelSPPlanType(sp.PlanType) +} + +// isAccountLevelSPPlanType reports whether planType names a Savings Plans +// product that applies account-wide rather than to one region. Compared +// against the SDK's own sptypes.SavingsPlanType members rather than bare +// string literals (feedback_sdk_enum_string_literals); those members are +// exactly the display strings recommendations/parser_sp.go's +// spPlanTypeDisplayString writes into SavingsPlanDetails.PlanType, so the two +// vocabularies cannot drift silently. +// +// EC2Instance is the one plan type deliberately absent: it is region-scoped, +// which is the whole point of this function. +// +// Unknown values deliberately return false: spPlanTypeDisplayString passes +// unrecognised SDK plan types through verbatim for forward compatibility, and +// a plan type this build does not know about must not be granted a +// region-filter exemption on the strength of a name nobody has checked. +func isAccountLevelSPPlanType(planType string) bool { + switch sptypes.SavingsPlanType(planType) { + case sptypes.SavingsPlanTypeCompute, sptypes.SavingsPlanTypeSagemaker, sptypes.SavingsPlanTypeDatabase: + return true + default: + return false + } } // regionSet builds the lookup set for a region filter, skipping blank diff --git a/providers/aws/service_client_test.go b/providers/aws/service_client_test.go index e3f35f9f4..c95233dea 100644 --- a/providers/aws/service_client_test.go +++ b/providers/aws/service_client_test.go @@ -488,3 +488,96 @@ func TestApplyRecommendationFilters_BlankRegionEntryIsNotAMatcher(t *testing.T) assert.Equal(t, "us-east-1", got[0].Region) }) } + +// TestApplyRecommendationFilters_RegionlessEC2InstanceSPNotExempt is the +// regression guard for the residual half of the over-broad region exemption. +// +// TestApplyRecommendationFilters_RegionlessReservationNotExempt above closed +// the hole for reservations by requiring CommitmentSavingsPlan. That is still +// not enough: only the ACCOUNT-LEVEL Savings Plans (Compute, SageMaker, +// Database) belong to no region. An EC2Instance Savings Plan is region-scoped, +// and extractEC2SPFields (recommendations/parser_sp.go) yields Region == "" +// whenever Cost Explorer omitted SavingsPlansDetails or its Region field, +// because aws.ToString maps a nil pointer to "". Keying the exemption on +// CommitmentSavingsPlan alone therefore let a region-scoped EC2Instance SP of +// unknown region survive an explicit "us-east-1 only" filter and be purchased +// in whatever region the service client resolved -- the same defect, one plan +// type over. +// +// This test fails on the CommitmentSavingsPlan-only isRegionAgnostic and +// passes once the exemption also requires isAccountLevelSPPlanType. +func TestApplyRecommendationFilters_RegionlessEC2InstanceSPNotExempt(t *testing.T) { + // An EC2Instance SP whose CE payload carried no region: region-scoped, + // but with nothing to match a region filter against. + regionlessEC2SP := common.Recommendation{ + Account: "111", + CommitmentType: common.CommitmentSavingsPlan, + Details: &common.SavingsPlanDetails{PlanType: "EC2Instance"}, + } + + t.Run("region-less EC2Instance SP is dropped by an include filter", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{regionlessEC2SP}, + common.RecommendationParams{Region: "us-east-1"}) + assert.Empty(t, got, + "an EC2Instance Savings Plan is region-scoped: with an unknown region it must not survive an explicit region filter") + }) + + t.Run("region-less EC2Instance SP is dropped by include_regions too", func(t *testing.T) { + got := applyRecommendationFilters([]common.Recommendation{regionlessEC2SP}, + common.RecommendationParams{IncludeRegions: []string{"us-east-1", "eu-west-1"}}) + assert.Empty(t, got) + }) + + t.Run("account-level SPs stay exempt", func(t *testing.T) { + for _, planType := range []string{"Compute", "SageMaker", "Database"} { + accountLevelSP := common.Recommendation{ + Account: "111", + CommitmentType: common.CommitmentSavingsPlan, + Details: &common.SavingsPlanDetails{PlanType: planType}, + } + got := applyRecommendationFilters([]common.Recommendation{accountLevelSP}, + common.RecommendationParams{Region: "us-east-1"}) + require.Lenf(t, got, 1, + "%s Savings Plans apply account-wide and must survive a region filter", planType) + } + }) + + t.Run("an unrecognised plan type is treated as region-scoped, not exempt", func(t *testing.T) { + // spPlanTypeDisplayString passes unknown SDK plan types through + // verbatim, so this is reachable on a future AWS product. The + // conservative direction is to filter it, not to exempt it. + unknownSP := common.Recommendation{ + Account: "111", + CommitmentType: common.CommitmentSavingsPlan, + Details: &common.SavingsPlanDetails{PlanType: "SomeFutureSP"}, + } + got := applyRecommendationFilters([]common.Recommendation{unknownSP}, + common.RecommendationParams{Region: "us-east-1"}) + assert.Empty(t, got, "a plan type this build does not recognise must not be granted the exemption") + }) + + t.Run("an SP carrying no Details at all is not exempt", func(t *testing.T) { + noDetailsSP := common.Recommendation{ + Account: "111", + CommitmentType: common.CommitmentSavingsPlan, + } + got := applyRecommendationFilters([]common.Recommendation{noDetailsSP}, + common.RecommendationParams{Region: "us-east-1"}) + assert.Empty(t, got, "with no Details there is no positive evidence the plan is account-level") + }) + + t.Run("an EC2Instance SP that DOES carry its region is filtered on that region", func(t *testing.T) { + ec2SPInUsEast := common.Recommendation{ + Account: "111", + CommitmentType: common.CommitmentSavingsPlan, + Details: &common.SavingsPlanDetails{PlanType: "EC2Instance", Region: "us-east-1"}, + } + kept := applyRecommendationFilters([]common.Recommendation{ec2SPInUsEast}, + common.RecommendationParams{Region: "us-east-1"}) + require.Len(t, kept, 1, "a matching region must still be kept") + + dropped := applyRecommendationFilters([]common.Recommendation{ec2SPInUsEast}, + common.RecommendationParams{Region: "eu-west-1"}) + assert.Empty(t, dropped, "a non-matching region must still be dropped") + }) +} From 01c056685236e29f6759cfc9b74f97d51074f2ce Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 19:54:24 +0200 Subject: [PATCH 73/83] fix(mcp): refuse a real purchase with no determinable account idempotencyKeyFor folds CredentialScope into the token, and CredentialScope returns "" when neither the explicit argument nor the ambient environment variable is set. So the SAME target account reached two ways derived two DIFFERENT tokens: omitting azure_subscription_id (ambient resolves to sub-X) gave "", while passing azure_subscription_id="sub-X" gave "sub-X". Every provider's dedupe is token-keyed, so the second call's lookup missed and bought a second commitment: Azure FindReservationOrderByIdempotencyToken GCP idempotentCommitmentName (token -> commitment name) AWS EC2/Redshift findRIByIdempotencyToken tag lookups, RDS/ElastiCache/OpenSearch/MemoryDB idempotencyGuard, Savings Plans CreateSavingsPlanInput.ClientToken Measured before the fix, one account, two call shapes: explicit sub-X -> token=ab1016c03e7b5928 purchases=1 ambient "" -> token=399b45fab938dd86 purchases=1 same token? false total purchases for one account: 2 Previewing without the account and then re-calling with it explicit is ordinary self-correcting model behavior, so this was a likely sequence rather than a corner case. The earlier review reasoned only about the opposite direction (one token spanning two accounts) and concluded an empty scope was safe because a purely-ambient provider is unambiguous for the process lifetime; that is true and irrelevant, since the hazard is two tokens for one account. A real purchase now requires a determinable account and is refused before any credential is resolved, naming the argument to pass. Previews are untouched, so pricing a purchase still needs no account. Explicit and ambient forms of a KNOWN account already converge, because CredentialScope falls back to the same environment variable the provider factory itself reads, so normal dedupe is preserved; only the both-absent case aliased and it is now unreachable. Deriving the effective account from the resolved client would be the stronger fix, but provider.ServiceClient exposes no account accessor, so that needs an interface change across every AWS/Azure/GCP service client. Failing closed removes the hazard by construction meanwhile: on an LLM-driven money path, making the operator name the target account before spending is the same posture as EnvEnableRealPurchases. The gates move into authorizeRealPurchase so ExecutePurchase stays under the gocyclo:10 pre-commit threshold (it hit 11) and so "what must be true before this server spends money" reads in one place. TestExecutePurchaseAmbientScopeCannotDoubleBuy reproduces the explicit-then-omitted sequence and asserts the second call never reaches the provider; verified failing before this change. Its companion TestExecutePurchaseExplicitAndAmbientAccountDedupe pins that the two ways of naming a known account still derive one token, so the fix removes the aliasing rather than relocating it. mcp/README.md documents the new requirement, and separately the already-landed Azure behavior change where an empty payment_option now fails loud instead of silently billing all-upfront (reachable from scheduled rows predating migration 000032). --- mcp/README.md | 2 + mcp/tools/aws_ec2_ri_test.go | 1 + mcp/tools/aws_elasticache_ri_test.go | 1 + mcp/tools/aws_rds_ri_test.go | 1 + mcp/tools/aws_savingsplans_test.go | 1 + mcp/tools/aws_simple_ri_test.go | 1 + mcp/tools/azure_compute_ri_test.go | 11 +- mcp/tools/gcp_computeengine_cud_test.go | 11 +- mcp/tools/purchase.go | 131 +++++++++++++--- mcp/tools/purchase_test.go | 194 +++++++++++++++++++++--- 10 files changed, 305 insertions(+), 49 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 43167e882..9cf111a72 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -99,6 +99,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. - A real purchase requires **both** `dry_run=false` **and** `confirm=true`. `dry_run=false` with `confirm=false` (or vice versa) is refused with a structured error, not silently downgraded to a preview or silently ignored. - 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 requires the **target account to be named**: `aws_profile` (AWS), `azure_subscription_id` (Azure), or `gcp_project_id` (GCP), either as the tool argument or via the matching environment variable the provider itself reads (e.g. `AWS_PROFILE`, `AZURE_SUBSCRIPTION_ID`). 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. - Provider/SDK failures surface their full error text back to the caller; nothing is swallowed. @@ -118,6 +119,7 @@ Understand these before enabling real purchases, especially in a shared or produ These are pre-existing behaviours in the underlying purchase clients, not something introduced by or specific to the MCP server -- flagged here so you know what to expect: - **Azure VM Reservations have no partial-upfront billing plan.** Azure honors exactly two billing plans, all-upfront and no-upfront (billed monthly, same total price -- Azure charges no premium for spreading payments), and `cudly_azure_compute_ri_purchase` defaults `payment_option` to no-upfront when omitted. `payment_option=partial-upfront` has no Azure equivalent and is rejected with an explicit error rather than silently purchased under all-upfront or no-upfront instead. +- **An Azure purchase with no `payment_option` now fails loud instead of silently billing all-upfront.** `reservations.BillingPlanForPaymentOption` used to fall through an empty payment option to Azure's own implicit default, which is Upfront -- charging the entire commitment immediately even though nobody asked for that schedule. It now returns an explicit error naming the missing value. This is reachable outside the MCP server: migration `000032` added `recommendations.payment_option` as `TEXT NOT NULL` defaulting to the empty string, and `internal/purchase/execution.go` passes `rec.Payment` through without a non-empty check, so **scheduled purchases created from rows predating that migration will now fail rather than silently charge upfront.** That is the intended trade (an unrequested full-upfront charge is worse than a refusal), but it is a live behaviour change: if a scheduled Azure execution starts failing with "no payment option was supplied", set the row's `payment_option` explicitly and re-run. The MCP tool itself is unaffected -- `cudly_azure_compute_ri_purchase` defaults `payment_option` to no-upfront before the client is reached. - **GCP Compute Engine CUDs commit resources, not instances.** `cudly_gcp_computeengine_cud_purchase` takes `vcpu_count` and `memory_gb` directly (a CUD is a vCPU+memory commitment), not an instance count -- there is no implicit vCPU-per-instance conversion. - **AWS Savings Plans searches require term/payment/lookback; `cudly_search_recommendations` defaults them.** Unlike EC2/RDS reservation searches (which AWS defaults server-side when `term_years`/`payment_option`/`lookback_period` are omitted), `GetSavingsPlansPurchaseRecommendation` rejects the call unless all three are set. When `service` targets a Savings Plans search on AWS, the tool fills in unset fields with `term_years=1` (1yr), `payment_option=no-upfront`, `lookback_period=30d` -- an explicitly supplied value is never overridden. Reservation searches (EC2/RDS/etc) are unaffected: omitted still means "search all". - **`region` on `cudly_search_recommendations` is a client-side post-filter for AWS reservation/Savings Plans searches.** `GetReservationPurchaseRecommendation` and `GetSavingsPlansPurchaseRecommendation` are account-level Cost Explorer calls with no region parameter -- AWS returns recommendations from every region the account has usage in. The tool filters the returned recommendations down to `region`/`include_regions` (minus `exclude_regions`) after the call; when no region constraint is supplied, all regions are returned as before. diff --git a/mcp/tools/aws_ec2_ri_test.go b/mcp/tools/aws_ec2_ri_test.go index 2d9e9ff9f..1e0b921e1 100644 --- a/mcp/tools/aws_ec2_ri_test.go +++ b/mcp/tools/aws_ec2_ri_test.go @@ -15,6 +15,7 @@ func boolPtr(b bool) *bool { return &b } func validEC2Args() ec2RIPurchaseArgs { return ec2RIPurchaseArgs{ + AWSProfile: "test-profile", Region: "us-east-1", InstanceType: "m5.large", Count: 3, diff --git a/mcp/tools/aws_elasticache_ri_test.go b/mcp/tools/aws_elasticache_ri_test.go index acb435789..047b225fb 100644 --- a/mcp/tools/aws_elasticache_ri_test.go +++ b/mcp/tools/aws_elasticache_ri_test.go @@ -13,6 +13,7 @@ import ( func validElastiCacheArgs() elasticacheRIPurchaseArgs { return elasticacheRIPurchaseArgs{ + AWSProfile: "test-profile", Region: "us-east-1", NodeType: "cache.r6g.large", Count: 3, diff --git a/mcp/tools/aws_rds_ri_test.go b/mcp/tools/aws_rds_ri_test.go index 664b69053..eef168717 100644 --- a/mcp/tools/aws_rds_ri_test.go +++ b/mcp/tools/aws_rds_ri_test.go @@ -13,6 +13,7 @@ import ( func validRDSArgs() rdsRIPurchaseArgs { return rdsRIPurchaseArgs{ + AWSProfile: "test-profile", Region: "us-east-1", InstanceClass: "db.r6g.large", Count: 2, diff --git a/mcp/tools/aws_savingsplans_test.go b/mcp/tools/aws_savingsplans_test.go index 871c9321b..420b1370d 100644 --- a/mcp/tools/aws_savingsplans_test.go +++ b/mcp/tools/aws_savingsplans_test.go @@ -14,6 +14,7 @@ import ( func validSavingsPlansArgs() savingsPlansPurchaseArgs { return savingsPlansPurchaseArgs{ + AWSProfile: "test-profile", SPType: "Compute", HourlyCommitment: 10.50, TermYears: 3, diff --git a/mcp/tools/aws_simple_ri_test.go b/mcp/tools/aws_simple_ri_test.go index a48c8d9bc..054068e5b 100644 --- a/mcp/tools/aws_simple_ri_test.go +++ b/mcp/tools/aws_simple_ri_test.go @@ -25,6 +25,7 @@ func simpleToolConstructors() map[string]func() Registration { func validSimpleArgs() simpleAWSRIPurchaseArgs { return simpleAWSRIPurchaseArgs{ + AWSProfile: "test-profile", Region: "us-east-1", ResourceType: "r6g.large", Count: 2, diff --git a/mcp/tools/azure_compute_ri_test.go b/mcp/tools/azure_compute_ri_test.go index c5c6719fc..e9f885b4d 100644 --- a/mcp/tools/azure_compute_ri_test.go +++ b/mcp/tools/azure_compute_ri_test.go @@ -19,11 +19,12 @@ import ( // don't care which honored schedule they exercise. func validAzureComputeArgs() azureComputeRIPurchaseArgs { return azureComputeRIPurchaseArgs{ - Region: "eastus", - VMSize: "Standard_D2s_v3", - Count: 2, - TermYears: 3, - PaymentOption: "all-upfront", + AzureSubscriptionID: "test-subscription", + Region: "eastus", + VMSize: "Standard_D2s_v3", + Count: 2, + TermYears: 3, + PaymentOption: "all-upfront", } } diff --git a/mcp/tools/gcp_computeengine_cud_test.go b/mcp/tools/gcp_computeengine_cud_test.go index 12a952b18..c97a7149e 100644 --- a/mcp/tools/gcp_computeengine_cud_test.go +++ b/mcp/tools/gcp_computeengine_cud_test.go @@ -13,11 +13,12 @@ import ( func validGCPCUDArgs() gcpComputeEngineCUDPurchaseArgs { return gcpComputeEngineCUDPurchaseArgs{ - Region: "us-central1", - MachineType: "n2-standard-4", - VCPUCount: 4, - MemoryGB: 16, - TermYears: 1, + GCPProjectID: "test-project", + Region: "us-central1", + MachineType: "n2-standard-4", + VCPUCount: 4, + MemoryGB: 16, + TermYears: 1, } } diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index a32bb3da5..2e178d7cb 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -124,6 +124,75 @@ func CredentialScope(explicit string, envVars ...string) string { return "" } +// credentialScopeArg names the tool argument that supplies the credential +// scope for a provider, so requireCredentialScope's refusal can tell the +// caller exactly which argument to pass. Unknown providers are an explicit +// error rather than a generic message: a provider added without a scope +// argument here would otherwise get a refusal it cannot act on +// (feedback_no_silent_fallbacks). +func credentialScopeArg(p common.ProviderType) (string, error) { + switch p { + case common.ProviderAWS: + return "aws_profile", nil + case common.ProviderAzure: + return "azure_subscription_id", nil + case common.ProviderGCP: + return "gcp_project_id", nil + default: + return "", fmt.Errorf("internal error: no credential-scope argument defined for provider %q", p) + } +} + +// requireCredentialScope refuses a REAL purchase whose credential scope could +// not be determined. Previews are unaffected (ExecutePurchase returns before +// calling this), so a caller can still price a purchase without naming an +// account. +// +// This exists because an empty scope is a DOUBLE-PURCHASE hazard, not merely +// an imprecise audit line. idempotencyKeyFor folds the scope into the token, +// and CredentialScope returns "" when neither the explicit argument nor the +// ambient environment variable is set. The same target account reached two +// ways therefore derives two DIFFERENT tokens: +// +// omit azure_subscription_id (ambient resolves to sub-X) -> scope "" +// pass azure_subscription_id="sub-X" -> scope "sub-X" +// +// Both purchase into sub-X, but every provider's dedupe is token-keyed -- +// Azure's FindReservationOrderByIdempotencyToken, GCP's +// idempotentCommitmentName, and on AWS the EC2/Redshift tag lookups +// (findRIByIdempotencyToken), the RDS/ElastiCache/OpenSearch/MemoryDB +// idempotencyGuard, and Savings Plans' CreateSavingsPlanInput.ClientToken -- +// so the second call's lookup misses and buys again. Previewing without the +// account and then re-calling with it explicit is ordinary self-correcting +// model behavior, which makes this a likely sequence rather than a corner +// case. +// +// The fix is to make "" unreachable for a real purchase. Note that explicit +// and ambient do NOT diverge once a value exists: CredentialScope falls back +// to the same environment variable the provider factory itself consults, so +// aws_profile="prod" and an ambient AWS_PROFILE=prod both yield "prod" and +// dedupe correctly. Only the both-absent case aliases, and this closes it. +// +// Deriving the effective account from the resolved client would be the +// stronger fix, but provider.ServiceClient exposes no account accessor +// (pkg/provider/interface.go), so that needs an interface change across every +// AWS/Azure/GCP service client and does not belong on this branch. Failing +// closed removes the hazard by construction in the meantime: on an +// LLM-driven money path, making the operator name the target account before +// spending is the same posture as EnvEnableRealPurchases. +func requireCredentialScope(p common.ProviderType, scope string) error { + if strings.TrimSpace(scope) != "" { + return nil + } + arg, err := credentialScopeArg(p) + if err != nil { + return err + } + return fmt.Errorf("refusing real purchase: the target account could not be determined, so pass %s explicitly. "+ + "Leaving it to ambient credentials derives a different idempotency token than naming the same account "+ + "explicitly, so a retry would not dedupe and could purchase twice. Dry runs do not require it", arg) +} + // ResolveDryRunConfirm applies the dry_run=true / confirm=false defaults to a // tool's optional flags. Go's zero value for bool cannot distinguish "caller // omitted the field" from "caller explicitly set it false", which is why both @@ -411,14 +480,44 @@ func logPurchaseOutcome(rec common.Recommendation, token, commitmentID string, s rec.Provider, rec.ResourceType, rec.Count, commitmentID, common.MaskToken(token)) } -// ExecutePurchase runs the shared dry_run/confirm safety gate, then the -// operator's EnvEnableRealPurchases authorization gate, and for a real -// purchase that clears both, resolves the service client and calls -// PurchaseCommitment with PurchaseSourceMCP and a derived idempotency token. -// It never calls ResolveClient in preview mode, so a preview makes zero -// provider/SDK calls; it also never calls ResolveClient when -// EnvEnableRealPurchases is not set, so a disabled server makes zero -// provider/SDK calls for a would-be real purchase either. +// authorizeRealPurchase runs every gate a real purchase must clear, in the +// order that touches the least. It is only ever reached with mode == +// modeExecute (ExecutePurchase returns the preview before calling it), so a +// dry run clears none of these and needs none of them. +// +// Gathered into one function rather than inlined so ExecutePurchase stays +// under the repo's gocyclo:10 pre-commit gate as gates accumulate, and so +// "what must be true before this server spends money" has a single place to +// read. Order matters: both the operator opt-in and the credential-scope +// check run before ResolveClient, so a refusal never resolves credentials or +// contacts a provider. +func authorizeRealPurchase(req PurchaseRequest, rec common.Recommendation) error { + // Operator authorization. See EnvEnableRealPurchases for why the + // model-supplied confirm flag alone is not enough. + if !realPurchasesEnabled() { + return fmt.Errorf("real purchases are disabled: set %s=1 to allow the MCP server to execute real purchases", + EnvEnableRealPurchases) + } + // 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 { + return err + } + if req.ResolveClient == nil { + return fmt.Errorf("internal error: no ResolveClient configured for real purchase") + } + return nil +} + +// ExecutePurchase runs the shared dry_run/confirm safety gate, then every +// gate in authorizeRealPurchase (operator opt-in via EnvEnableRealPurchases, +// and a determinable target account), and for a real purchase that clears +// them all, resolves the service client and calls PurchaseCommitment with +// PurchaseSourceMCP and a derived idempotency token. It never calls +// ResolveClient in preview mode, so a preview makes zero provider/SDK calls; +// it also never calls ResolveClient when any authorizeRealPurchase gate +// refuses, so a disabled server -- or one that cannot tell which account a +// purchase would land in -- makes zero provider/SDK calls either. func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseResponse, error) { mode, err := decidePurchaseMode(req.DryRun, req.Confirm) if err != nil { @@ -438,18 +537,14 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons }, nil } - // Operator authorization gate: mode == modeExecute here (preview already - // returned above), so this is the single chokepoint every purchase tool - // funnels through before a provider is ever touched. See - // EnvEnableRealPurchases for why confirm alone is not enough. - if !realPurchasesEnabled() { - return nil, fmt.Errorf("real purchases are disabled: set %s=1 to allow the MCP server to execute real purchases", - EnvEnableRealPurchases) + // Named authErr rather than err: `err :=` here trips govet's shadow check + // against the err declared above, while `err =` trips gocritic's + // sloppyReassign. A distinct name satisfies both (same reason as + // validateSavingsPlanArgs' commitErr in aws_savingsplans.go). + if authErr := authorizeRealPurchase(req, rec); authErr != nil { + return nil, authErr } - if req.ResolveClient == nil { - return nil, fmt.Errorf("internal error: no ResolveClient configured for real purchase") - } client, err := req.ResolveClient(ctx) if err != nil { return nil, fmt.Errorf("resolve %s service client: %w", rec.Provider, err) diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 137e1e0b3..48559ffa6 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -146,8 +146,8 @@ func TestExecutePurchaseDryRunNeverCallsProvider(t *testing.T) { Region: "us-east-1", Recommendation: testRecommendationWithCost(), DryRun: true, - Confirm: true, - ResolveClient: resolve, + Confirm: true, CredentialScope: "test-scope", + ResolveClient: resolve, }) require.NoError(t, err) @@ -230,8 +230,8 @@ func TestExecutePurchaseRealPurchasePopulatesTermYears(t *testing.T) { Region: "us-east-1", Recommendation: testRecommendation(), DryRun: false, - Confirm: true, - ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + Confirm: true, CredentialScope: "test-scope", + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, }) require.NoError(t, err) require.NotNil(t, resp) @@ -307,7 +307,7 @@ func TestExecutePurchaseRealPurchaseGate(t *testing.T) { rec := testRecommendation() realPurchaseRequest := func(fake *fakeServiceClient, dryRun bool) PurchaseRequest { return PurchaseRequest{ - Region: "us-east-1", Recommendation: rec, DryRun: dryRun, Confirm: true, + Region: "us-east-1", Recommendation: rec, DryRun: dryRun, Confirm: true, CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, } } @@ -397,8 +397,8 @@ func TestExecutePurchaseRealPurchaseCallsProviderWithMCPSource(t *testing.T) { Region: "us-east-1", Recommendation: testRecommendation(), DryRun: false, - Confirm: true, - ResolveClient: resolve, + Confirm: true, CredentialScope: "test-scope", + ResolveClient: resolve, }) require.NoError(t, err) @@ -422,13 +422,13 @@ func TestExecutePurchaseSameRequestDerivesSameToken(t *testing.T) { fake2 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} _, err := ExecutePurchase(context.Background(), PurchaseRequest{ - Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake1, nil }, }) require.NoError(t, err) _, err = ExecutePurchase(context.Background(), PurchaseRequest{ - Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake2, nil }, }) require.NoError(t, err) @@ -441,7 +441,7 @@ func TestExecutePurchaseSameRequestDerivesSameToken(t *testing.T) { rec2.Count = 4 fake3 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} _, err = ExecutePurchase(context.Background(), PurchaseRequest{ - Region: "us-east-1", Recommendation: rec2, DryRun: false, Confirm: true, + Region: "us-east-1", Recommendation: rec2, DryRun: false, Confirm: true, CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake3, nil }, }) require.NoError(t, err) @@ -460,8 +460,8 @@ func TestExecutePurchaseProviderErrorSurfaced(t *testing.T) { Region: "us-east-1", Recommendation: testRecommendation(), DryRun: false, - Confirm: true, - ResolveClient: resolve, + Confirm: true, CredentialScope: "test-scope", + ResolveClient: resolve, }) require.Error(t, err) @@ -481,8 +481,8 @@ func TestExecutePurchaseResolveClientErrorSurfaced(t *testing.T) { Region: "us-east-1", Recommendation: testRecommendation(), DryRun: false, - Confirm: true, - ResolveClient: resolve, + Confirm: true, CredentialScope: "test-scope", + ResolveClient: resolve, }) require.Error(t, err) @@ -596,14 +596,14 @@ func TestExecutePurchaseNonceThreadedThroughToToken(t *testing.T) { fake1 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} _, err := ExecutePurchase(context.Background(), PurchaseRequest{ - Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, Nonce: "call-1", + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, Nonce: "call-1", CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake1, nil }, }) require.NoError(t, err) fake2 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} _, err = ExecutePurchase(context.Background(), PurchaseRequest{ - Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, Nonce: "call-2", + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, Nonce: "call-2", CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake2, nil }, }) require.NoError(t, err) @@ -613,7 +613,7 @@ func TestExecutePurchaseNonceThreadedThroughToToken(t *testing.T) { fake3 := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} _, err = ExecutePurchase(context.Background(), PurchaseRequest{ - Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, Nonce: "call-1", + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, Nonce: "call-1", CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake3, nil }, }) require.NoError(t, err) @@ -773,7 +773,7 @@ func TestExecutePurchaseAuditLogging(t *testing.T) { fake := &fakeServiceClient{purchaseErr: errors.New("insufficient capacity")} out := capture(func() { _, err := ExecutePurchase(context.Background(), PurchaseRequest{ - Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, }) require.Error(t, err) @@ -794,7 +794,7 @@ func TestExecutePurchaseAuditLogging(t *testing.T) { fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: false, Error: nil}} out := capture(func() { _, err := ExecutePurchase(context.Background(), PurchaseRequest{ - Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, }) require.NoError(t, err, "a provider-reported failure surfaces via the response, not a Go error") @@ -878,7 +878,7 @@ func TestArcheraOfferOnlyAfterSuccessfulRealPurchase(t *testing.T) { Error: errors.New("insufficient capacity"), }} resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ - Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, }) require.NoError(t, err) @@ -893,7 +893,7 @@ func TestArcheraOfferOnlyAfterSuccessfulRealPurchase(t *testing.T) { CommitmentID: "ri-abc123", }} resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ - Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, CredentialScope: "test-scope", ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, }) require.NoError(t, err) @@ -919,3 +919,155 @@ func TestArcheraOfferOnlyAfterSuccessfulRealPurchase(t *testing.T) { assert.Contains(t, body, "sponsorship_disclosure") }) } + +// TestExecutePurchaseAmbientScopeCannotDoubleBuy is the regression guard for +// the credential-scope aliasing double-purchase found in the independent +// re-review of #1495. +// +// idempotencyKeyFor folds CredentialScope into the token, and CredentialScope +// returns "" when neither the explicit argument nor the ambient environment +// variable is set. So the SAME target account reached two ways derived two +// DIFFERENT tokens: omitting azure_subscription_id (ambient resolves to +// sub-X) gave "", while passing azure_subscription_id="sub-X" gave "sub-X". +// Every provider's dedupe is token-keyed -- Azure's +// FindReservationOrderByIdempotencyToken, GCP's idempotentCommitmentName, and +// on AWS the EC2/Redshift tag lookups, the RDS/ElastiCache/OpenSearch/MemoryDB +// idempotencyGuard, and Savings Plans' ClientToken -- so the second call's +// lookup missed and bought a SECOND commitment. +// +// The sequence below is the realistic one, not a contrived edge: a model +// previews or purchases without naming the account, then re-calls the same +// purchase with the account explicit (self-correction, or a retry after a +// timeout). Before the fix that bought twice. +// +// The earlier review reasoned only about the opposite direction (one token +// spanning two accounts) and concluded an empty scope was safe because a +// purely-ambient provider is unambiguous for the process lifetime. That is +// true and irrelevant: the hazard is two tokens for ONE account. +func TestExecutePurchaseAmbientScopeCannotDoubleBuy(t *testing.T) { + t.Parallel() + rec := testRecommendation() + realPurchase := func(scope string, fake *fakeServiceClient) (*PurchaseResponse, error) { + return ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + CredentialScope: scope, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + } + + t.Run("explicit account then the same account omitted does not buy twice", func(t *testing.T) { + t.Parallel() + // Call 1: the account named explicitly. This is a real purchase. + first := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "ri-1"}} + resp, err := realPurchase("sub-X", first) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, 1, first.purchaseCalls) + + // Call 2: identical purchase, but the account left to ambient + // credentials. It resolves to the SAME sub-X, so this must not + // become a second commitment. + second := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "ri-2"}} + _, err = realPurchase("", second) + + require.Error(t, err, "an undeterminable account must not reach the provider on a real purchase") + assert.Equal(t, 0, second.purchaseCalls, + "the second call must not purchase: it targets the same account as the first and would double-buy") + assert.Contains(t, err.Error(), "aws_profile", + "the refusal must name the argument the caller has to pass") + }) + + t.Run("a real purchase with no determinable account is refused before any provider call", func(t *testing.T) { + t.Parallel() + resolveCalled := false + _, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + CredentialScope: "", + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { + resolveCalled = true + return nil, errors.New("ResolveClient must not be called when the scope is undeterminable") + }, + }) + require.Error(t, err) + assert.False(t, resolveCalled, "the refusal must not resolve credentials") + }) + + t.Run("whitespace-only scope counts as absent", func(t *testing.T) { + t.Parallel() + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + _, err := realPurchase(" ", fake) + require.Error(t, err) + assert.Equal(t, 0, fake.purchaseCalls) + }) + + t.Run("a dry run is unaffected and needs no account", func(t *testing.T) { + t.Parallel() + resolveCalled := false + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: true, Confirm: true, + CredentialScope: "", + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { + resolveCalled = true + return nil, errors.New("dry run must not resolve a client") + }, + }) + require.NoError(t, err, "previewing must not require naming an account") + require.NotNil(t, resp) + assert.True(t, resp.DryRun) + assert.False(t, resolveCalled) + }) +} + +// TestExecutePurchaseExplicitAndAmbientAccountDedupe is the other half of the +// invariant TestExecutePurchaseAmbientScopeCannotDoubleBuy guards. Refusing an +// undeterminable account removes the aliasing only if the two ways of naming a +// KNOWN account still converge: CredentialScope falls back to the same +// environment variable the provider factory itself consults, so passing +// aws_profile="prod" and inheriting AWS_PROFILE=prod must derive ONE token. +// Without this, the fix would merely move the aliasing rather than remove it. +// +// Deliberately not parallel: t.Setenv cannot be used by a test with a parallel +// ancestor. +func TestExecutePurchaseExplicitAndAmbientAccountDedupe(t *testing.T) { + const envVar = "CUDLY_TEST_ALIAS_PROFILE" + t.Setenv(envVar, "prod") + rec := testRecommendation() + + realPurchase := func(scope string, fake *fakeServiceClient) error { + _, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", Recommendation: rec, DryRun: false, Confirm: true, + CredentialScope: scope, + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + return err + } + + explicit := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + require.NoError(t, realPurchase(CredentialScope("prod", envVar), explicit)) + + ambient := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + require.NoError(t, realPurchase(CredentialScope("", envVar), ambient)) + + assert.Equal(t, explicit.lastOpts.IdempotencyToken, ambient.lastOpts.IdempotencyToken, + "naming the account explicitly and inheriting it from the environment target the same account and must dedupe") +} + +// TestCredentialScopeArgNamesEveryProvider pins that every provider a purchase +// tool can build a Recommendation for has a scope argument to name in the +// refusal, and that an unrecognized provider is an explicit internal error +// rather than a refusal the caller cannot act on. +func TestCredentialScopeArgNamesEveryProvider(t *testing.T) { + t.Parallel() + for prov, want := range map[common.ProviderType]string{ + common.ProviderAWS: "aws_profile", + common.ProviderAzure: "azure_subscription_id", + common.ProviderGCP: "gcp_project_id", + } { + got, err := credentialScopeArg(prov) + require.NoErrorf(t, err, "provider %q must have a scope argument", prov) + assert.Equal(t, want, got) + } + + _, err := credentialScopeArg(common.ProviderType("nimbus")) + require.Error(t, err, "an unknown provider must fail loud rather than emit an unactionable refusal") +} From d76aa7c00cc072c69fd9bd34a219630fa0c444e4 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 22:19:37 +0200 Subject: [PATCH 74/83] fix(mcp): fan out reservation searches over every term and payment option cudly_search_recommendations documented "omitted means search all" for reservation searches, and mcp/README.md stated AWS defaults term/payment server-side when they are omitted. Neither was true. GetReservationPurchaseRecommendation takes exactly one TermInYears and one PaymentOption per request and returns results only for that cell. Omitting them sent Cost Explorer EMPTY enum values (convertTermInYears and convertPaymentOption both silently map unrecognized input to ""), AWS quietly applied its own 1yr/all-upfront default, and the caller received one of six purchasable offers while the tool claimed it had searched them all. Verified against a live account: the omitted-field search returned only 1yr/all-upfront at 40% savings, hiding 3yr/all-upfront at 63% on the identical t4g.nano. A model told "omitted searches all terms" would report the worse offer as the only option. Expand each omitted dimension to its full menu and issue one call per combination (6 when both are omitted, 2 or 3 when one is). Because each call now carries a concrete term and payment option, the AWS parser tags every returned recommendation with the offer that produced it, so the money figures are no longer blank and unattributable. A failing combination fails the whole search rather than being skipped: returning five of six offers is indistinguishable from "these are all your options" and would recreate the same defect. Results are always a non-nil slice, so a no-results search serializes as [] instead of sometimes [] and sometimes null. lookback_period is deliberately NOT fanned out; it is the usage evidence behind an offer rather than another offer to choose from, so AWS's server-side default stands. The schema text and README now say so instead of claiming it searches all lookback windows. TestSearchRecommendationsEC2NoDefaultsInjected asserted the incorrect premise in its own doc comment and is rewritten to guard what it was actually for: the Savings Plans defaults must not narrow a reservation search, and lookback_period must still reach AWS blank. --- mcp/README.md | 3 +- mcp/tools/search_recommendations.go | 130 +++++++++++- mcp/tools/search_recommendations_test.go | 247 +++++++++++++++++++++-- 3 files changed, 357 insertions(+), 23 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 9cf111a72..a3201cbd0 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -121,7 +121,8 @@ These are pre-existing behaviours in the underlying purchase clients, not someth - **Azure VM Reservations have no partial-upfront billing plan.** Azure honors exactly two billing plans, all-upfront and no-upfront (billed monthly, same total price -- Azure charges no premium for spreading payments), and `cudly_azure_compute_ri_purchase` defaults `payment_option` to no-upfront when omitted. `payment_option=partial-upfront` has no Azure equivalent and is rejected with an explicit error rather than silently purchased under all-upfront or no-upfront instead. - **An Azure purchase with no `payment_option` now fails loud instead of silently billing all-upfront.** `reservations.BillingPlanForPaymentOption` used to fall through an empty payment option to Azure's own implicit default, which is Upfront -- charging the entire commitment immediately even though nobody asked for that schedule. It now returns an explicit error naming the missing value. This is reachable outside the MCP server: migration `000032` added `recommendations.payment_option` as `TEXT NOT NULL` defaulting to the empty string, and `internal/purchase/execution.go` passes `rec.Payment` through without a non-empty check, so **scheduled purchases created from rows predating that migration will now fail rather than silently charge upfront.** That is the intended trade (an unrequested full-upfront charge is worse than a refusal), but it is a live behaviour change: if a scheduled Azure execution starts failing with "no payment option was supplied", set the row's `payment_option` explicitly and re-run. The MCP tool itself is unaffected -- `cudly_azure_compute_ri_purchase` defaults `payment_option` to no-upfront before the client is reached. - **GCP Compute Engine CUDs commit resources, not instances.** `cudly_gcp_computeengine_cud_purchase` takes `vcpu_count` and `memory_gb` directly (a CUD is a vCPU+memory commitment), not an instance count -- there is no implicit vCPU-per-instance conversion. -- **AWS Savings Plans searches require term/payment/lookback; `cudly_search_recommendations` defaults them.** Unlike EC2/RDS reservation searches (which AWS defaults server-side when `term_years`/`payment_option`/`lookback_period` are omitted), `GetSavingsPlansPurchaseRecommendation` rejects the call unless all three are set. When `service` targets a Savings Plans search on AWS, the tool fills in unset fields with `term_years=1` (1yr), `payment_option=no-upfront`, `lookback_period=30d` -- an explicitly supplied value is never overridden. Reservation searches (EC2/RDS/etc) are unaffected: omitted still means "search all". +- **AWS Savings Plans searches require term/payment/lookback; `cudly_search_recommendations` defaults them.** Unlike EC2/RDS reservation searches, `GetSavingsPlansPurchaseRecommendation` rejects the call unless all three are set. When `service` targets a Savings Plans search on AWS, the tool fills in unset fields with `term_years=1` (1yr), `payment_option=no-upfront`, `lookback_period=30d` -- an explicitly supplied value is never overridden. A Savings Plans search therefore resolves to exactly one (term, payment, lookback) triple and issues exactly one Cost Explorer call. +- **Reservation searches fan out over every term and payment option when you omit them.** `GetReservationPurchaseRecommendation` accepts exactly one `TermInYears` and one `PaymentOption` per request and returns recommendations only for that cell -- there is no "give me every variant" mode. So when `term_years` and/or `payment_option` are omitted on an EC2/RDS/ElastiCache/etc search, the tool expands the omitted dimension to its full menu and issues one call per combination (6 when both are omitted, 2 or 3 when one is), returning the concatenated results. Each returned recommendation carries the `term`/`payment_option` of the request that produced it, so its money figures are attributable to a specific offer. If any one combination fails, the whole search fails rather than returning a partial menu -- five of six offers is indistinguishable from "these are all your options". Note that `lookback_period` is NOT fanned out: omitting it leaves Cost Explorer's own server-side default (7 days) in place, since the lookback window is the usage evidence behind an offer rather than another offer to choose from. - **`region` on `cudly_search_recommendations` is a client-side post-filter for AWS reservation/Savings Plans searches.** `GetReservationPurchaseRecommendation` and `GetSavingsPlansPurchaseRecommendation` are account-level Cost Explorer calls with no region parameter -- AWS returns recommendations from every region the account has usage in. The tool filters the returned recommendations down to `region`/`include_regions` (minus `exclude_regions`) after the call; when no region constraint is supplied, all regions are returned as before. ## Deployment model diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index 740b1f464..e76df2c4a 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -29,9 +29,9 @@ type searchRecommendationsArgs struct { Region string `json:"region,omitempty" jsonschema:"region to search; omit for account/global-level services such as Savings Plans"` IncludeRegions []string `json:"include_regions,omitempty" jsonschema:"restrict the search to these regions, in addition to (or instead of) region"` ExcludeRegions []string `json:"exclude_regions,omitempty" jsonschema:"exclude these regions from the search"` - LookbackPeriod string `json:"lookback_period,omitempty" jsonschema:"cost/usage lookback window backing the recommendation; AWS Savings Plans searches default to 30d when omitted (AWS requires a value); reservation searches (EC2/RDS/etc) leave it omitted to search all lookback windows"` - TermYears int `json:"term_years,omitempty" jsonschema:"commitment term filter; AWS Savings Plans searches default to 1 (1yr, no-upfront) when omitted (AWS requires a value); reservation searches (EC2/RDS/etc) leave it omitted to search all terms"` - PaymentOption string `json:"payment_option,omitempty" jsonschema:"payment schedule filter; AWS Savings Plans searches default to no-upfront when omitted (AWS requires a value); reservation searches (EC2/RDS/etc) leave it omitted to search all payment options"` + LookbackPeriod string `json:"lookback_period,omitempty" jsonschema:"cost/usage lookback window backing the recommendation; AWS Savings Plans searches default to 30d when omitted (AWS requires a value); reservation searches (EC2/RDS/etc) fall back to AWS's own server-side default (7 days) when omitted"` + TermYears int `json:"term_years,omitempty" jsonschema:"commitment term filter; AWS Savings Plans searches default to 1 (1yr, no-upfront) when omitted (AWS requires a value); reservation searches (EC2/RDS/etc) leave it omitted to search BOTH 1yr and 3yr and return a result per term"` + PaymentOption string `json:"payment_option,omitempty" jsonschema:"payment schedule filter; AWS Savings Plans searches default to no-upfront when omitted (AWS requires a value); reservation searches (EC2/RDS/etc) leave it omitted to search ALL THREE payment options and return a result per option"` AccountFilter []string `json:"account_filter,omitempty" jsonschema:"restrict the search to these account/subscription/project IDs"` IncludeSPTypes []string `json:"include_sp_types,omitempty" jsonschema:"AWS Savings Plans types to include; omit for all"` ExcludeSPTypes []string `json:"exclude_sp_types,omitempty" jsonschema:"AWS Savings Plans types to exclude"` @@ -112,14 +112,123 @@ func (t *searchRecommendationsTool) handle(ctx context.Context, _ *mcp.CallToolR return nil, searchRecommendationsResult{}, fmt.Errorf("get %s recommendations client: %w", providerType, err) } - recs, err := recClient.GetRecommendations(ctx, recommendationParamsFromArgs(service, term, args)) + recs, err := fetchSearchCombos(ctx, recClient, providerType, service, term, args) if err != nil { - return nil, searchRecommendationsResult{}, fmt.Errorf("get recommendations: %w", err) + return nil, searchRecommendationsResult{}, err } return nil, searchRecommendationsResult{Count: len(recs), Recommendations: recs}, nil } +// searchCombo is one (term, payment option) pair to query. Cost Explorer's +// GetReservationPurchaseRecommendation accepts exactly one term and one +// payment option per request and returns recommendations only for that cell +// -- there is no "give me every variant" mode -- so covering the full menu +// takes one call per combo. +type searchCombo struct { + term string + payment string +} + +// allSearchTerms and allSearchPaymentOptions are the full reservation menus +// fanned out over when the caller omits term_years / payment_option. They +// mirror defaultDiscoveryTerms and defaultDiscoveryPaymentOptions in +// providers/aws/recommendations/client.go, which the scheduler's discovery +// sweep already fans out over for the same reason. +var ( + allSearchTerms = []string{TermOneYear.RecommendationTerm(), TermThreeYear.RecommendationTerm()} + allSearchPaymentOptions = []string{ + string(PaymentOptionAllUpfront), + string(PaymentOptionPartialUpfront), + string(PaymentOptionNoUpfront), + } +) + +// searchCombos returns every (term, payment) pair the search must query. +// +// For an AWS reservation search (EC2/RDS/ElastiCache/...), an omitted +// term_years or payment_option means "search all", so the omitted dimension +// expands to its full menu and the result is the Cartesian product: 6 combos +// when both are omitted, 2 or 3 when one is, 1 when both are supplied. +// +// Without this expansion, omitting the fields sent Cost Explorer an EMPTY +// TermInYears/PaymentOption (convertTermInYears and convertPaymentOption both +// silently map unrecognized input to ""), AWS quietly applied its own default +// of 1yr/all-upfront, and the caller saw exactly ONE of the six purchasable +// options while the tool claimed to have searched them all. On a real account +// that hid the best offer outright: 1yr/all-upfront saved 40% where +// 3yr/all-upfront saved 63% on the identical instance. +// +// Every other search -- Savings Plans (own required-field defaulting, see +// applySavingsPlansSearchDefaults), Azure (term/payment come back off the +// Advisor response, they are not request filters), GCP (no term/payment +// concept in its recommendations path) -- queries once with whatever the +// caller supplied, since fanning out there would re-issue the same query and +// duplicate results. +func searchCombos(providerType common.ProviderType, service common.ServiceType, term, payment string) []searchCombo { + if providerType != common.ProviderAWS || common.IsSavingsPlan(service) { + return []searchCombo{{term: term, payment: payment}} + } + + terms := []string{term} + if term == "" { + terms = allSearchTerms + } + payments := []string{payment} + if payment == "" { + payments = allSearchPaymentOptions + } + + combos := make([]searchCombo, 0, len(terms)*len(payments)) + for _, tm := range terms { + for _, pay := range payments { + combos = append(combos, searchCombo{term: tm, payment: pay}) + } + } + return combos +} + +// fetchSearchCombos queries every combo searchCombos selected and returns the +// concatenated recommendations, always as a non-nil slice so a no-results +// search serializes as [] rather than null. +// +// Each combo is sent with a CONCRETE term and payment option, which is also +// what makes the returned recommendations self-describing: the AWS parser +// tags each one with the term/payment of the request that produced it +// (providers/aws/recommendations/parser_ri.go), so a fanned-out result set +// says which offer each set of money figures belongs to instead of leaving +// them blank and unattributable. +// +// A failing combo fails the whole search rather than being skipped. The +// scheduler's equivalent sweep (GetRecommendationsForService) tolerates +// per-combo errors because partial progress beats none in a batch job, but +// here the caller is choosing what to BUY: silently returning 5 of 6 offers +// is indistinguishable from "these are all your options" and recreates the +// exact defect this fan-out exists to fix. The error names the combo so a +// retry is targeted. +func fetchSearchCombos( + ctx context.Context, + recClient provider.RecommendationsClient, + providerType common.ProviderType, + service common.ServiceType, + term string, + args searchRecommendationsArgs, +) ([]common.Recommendation, error) { + combos := searchCombos(providerType, service, term, args.PaymentOption) + recs := make([]common.Recommendation, 0) + for _, combo := range combos { + got, err := recClient.GetRecommendations(ctx, recommendationParamsFromArgs(service, combo, args)) + if err != nil { + if len(combos) == 1 { + return nil, fmt.Errorf("get recommendations: %w", err) + } + return nil, fmt.Errorf("get recommendations (term=%s, payment_option=%s): %w", combo.term, combo.payment, err) + } + recs = append(recs, got...) + } + return recs, nil +} + // validateSearchArgs validates every money-neutral-but-still-typed field on // args that does not require a live provider (provider name, payment // option, lookback_period, term, Savings Plans type filters), returning the @@ -362,14 +471,17 @@ func providerConfigFromArgs(providerType common.ProviderType, args searchRecomme } // recommendationParamsFromArgs builds the common.RecommendationParams for -// the already-validated service and term. -func recommendationParamsFromArgs(service common.ServiceType, term string, args searchRecommendationsArgs) *common.RecommendationParams { +// the already-validated service and one combo from searchCombos. Term and +// payment option come from the combo, NOT from args, so a fanned-out search +// sends each (term, payment) cell concretely; every other field is shared +// across the fan-out and comes from args. +func recommendationParamsFromArgs(service common.ServiceType, combo searchCombo, args searchRecommendationsArgs) *common.RecommendationParams { return &common.RecommendationParams{ Service: service, Region: args.Region, LookbackPeriod: args.LookbackPeriod, - Term: term, - PaymentOption: args.PaymentOption, + Term: combo.term, + PaymentOption: combo.payment, AccountFilter: args.AccountFilter, IncludeRegions: args.IncludeRegions, ExcludeRegions: args.ExcludeRegions, diff --git a/mcp/tools/search_recommendations_test.go b/mcp/tools/search_recommendations_test.go index 1d6bbda92..6f07759ca 100644 --- a/mcp/tools/search_recommendations_test.go +++ b/mcp/tools/search_recommendations_test.go @@ -19,13 +19,38 @@ type fakeRecommendationsClient struct { recs []common.Recommendation err error calls int + + // allParams records the params of EVERY call, not just the last, so + // fan-out tests can assert the exact set of (term, payment) combos a + // search issued rather than only the combo that happened to run last. + allParams []common.RecommendationParams + + // errOnCall, when non-zero, makes the 1-based call with that index fail + // with errForCombo. Used to prove one failing combo fails the whole + // search instead of silently returning the combos that succeeded. + errOnCall int + errForCombo error } func (f *fakeRecommendationsClient) GetRecommendations(_ context.Context, params *common.RecommendationParams) ([]common.Recommendation, error) { f.calls++ f.lastParams = params + f.allParams = append(f.allParams, *params) + if f.errOnCall != 0 && f.calls == f.errOnCall { + return nil, f.errForCombo + } return f.recs, f.err } + +// combos returns the (term, payment) pairs recorded across every call, in +// call order, for concise assertions in the fan-out tests. +func (f *fakeRecommendationsClient) combos() []searchCombo { + got := make([]searchCombo, 0, len(f.allParams)) + for _, p := range f.allParams { + got = append(got, searchCombo{term: p.Term, payment: p.PaymentOption}) + } + return got +} func (f *fakeRecommendationsClient) GetRecommendationsForService(_ context.Context, _ common.ServiceType) ([]common.Recommendation, error) { return f.recs, f.err } @@ -84,18 +109,27 @@ func TestSearchRecommendationsHappyPath(t *testing.T) { fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} tool := newTestSearchTool(fp) + // term_years/payment_option are supplied so this stays a single-combo + // search: the fan-out that omitting them triggers has its own dedicated + // tests below, and pinning one combo here keeps this focused on "the + // basic search works and forwards its params". _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ - Provider: "aws", - Service: "ec2", - Region: "us-east-1", + Provider: "aws", + Service: "ec2", + Region: "us-east-1", + TermYears: 1, + PaymentOption: "all-upfront", }) require.NoError(t, err) + assert.Equal(t, 1, client.calls) assert.Equal(t, 1, result.Count) assert.Equal(t, recs, result.Recommendations) require.NotNil(t, client.lastParams) assert.Equal(t, common.ServiceEC2, client.lastParams.Service) assert.Equal(t, "us-east-1", client.lastParams.Region) + assert.Equal(t, "1yr", client.lastParams.Term) + assert.Equal(t, "all-upfront", client.lastParams.PaymentOption) } // TestSearchRecommendationsForwardsRegionFilters proves finding B of the @@ -312,12 +346,17 @@ func TestSearchRecommendationsSPExplicitTermYearsPreserved(t *testing.T) { assert.Equal(t, "30d", client.lastParams.LookbackPeriod, "lookback_period still defaults when omitted") } -// TestSearchRecommendationsEC2NoDefaultsInjected proves the SP defaults are -// scoped to AWS Savings Plans searches only: an EC2 (reservation) search -// omitting term/payment/lookback must reach the provider with those fields -// still blank, since GetReservationPurchaseRecommendation already searches -// all terms/payment options/lookback windows when they're omitted -- -// defaulting them here would wrongly narrow those results. +// TestSearchRecommendationsEC2NoDefaultsInjected proves the Savings Plans +// defaults are scoped to AWS Savings Plans searches only: an EC2 +// (reservation) search omitting term/payment/lookback must never be narrowed +// to the SP trio of 1yr / no-upfront / 30d. +// +// lookback_period is the field that must still arrive BLANK on every call: +// unlike term and payment option (which the fan-out now supplies concretely, +// see TestSearchRecommendationsFansOutAllCombos...), Cost Explorer applies +// its own server-side default for the lookback window, and injecting 30d +// here would silently change the usage evidence behind every reservation +// recommendation. func TestSearchRecommendationsEC2NoDefaultsInjected(t *testing.T) { t.Parallel() client := &fakeRecommendationsClient{} @@ -330,10 +369,192 @@ func TestSearchRecommendationsEC2NoDefaultsInjected(t *testing.T) { }) require.NoError(t, err) - require.NotNil(t, client.lastParams) - assert.Empty(t, client.lastParams.Term) - assert.Empty(t, client.lastParams.PaymentOption) - assert.Empty(t, client.lastParams.LookbackPeriod) + require.NotEmpty(t, client.allParams) + for i, p := range client.allParams { + assert.Emptyf(t, p.LookbackPeriod, "call %d must leave lookback_period to AWS's own default", i) + } + // The SP defaulting combo (1yr + no-upfront) is only ever reached here as + // one cell of the full reservation fan-out, never as an injected default + // that collapses the search to a single narrowed result. + assert.Greater(t, client.calls, 1, "an EC2 search must not collapse to one narrowed combo") +} + +// TestSearchRecommendationsFansOutAllCombosWhenTermAndPaymentOmitted is the +// regression guard for the defect where "omitted means search all" was false +// for reservations. The tool sent Cost Explorer an empty +// TermInYears/PaymentOption, AWS silently applied its own 1yr/all-upfront +// default, and the caller got ONE of six purchasable offers while the tool's +// own schema claimed it had searched them all -- on a real account that hid +// the best offer, a 3yr/all-upfront saving 63% where the returned +// 1yr/all-upfront saved 40% on the identical instance. +func TestSearchRecommendationsFansOutAllCombosWhenTermAndPaymentOmitted(t *testing.T) { + t.Parallel() + recs := []common.Recommendation{{Provider: common.ProviderAWS, ResourceType: "t4g.nano", Count: 1}} + client := &fakeRecommendationsClient{recs: recs} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + Region: "eu-west-1", + }) + + require.NoError(t, err) + assert.ElementsMatch(t, []searchCombo{ + {term: "1yr", payment: "all-upfront"}, + {term: "1yr", payment: "partial-upfront"}, + {term: "1yr", payment: "no-upfront"}, + {term: "3yr", payment: "all-upfront"}, + {term: "3yr", payment: "partial-upfront"}, + {term: "3yr", payment: "no-upfront"}, + }, client.combos(), "every purchasable term/payment offer must be searched") + // Every combo's recommendations reach the caller, so the full menu is + // returned rather than one arbitrary cell of it. + assert.Equal(t, 6, result.Count) + assert.Len(t, result.Recommendations, 6) + // Shared fields still ride along on each fanned-out call. + for i, p := range client.allParams { + assert.Equalf(t, "eu-west-1", p.Region, "call %d lost the region filter", i) + } +} + +// TestSearchRecommendationsFansOutOverPaymentOptionsOnly proves the fan-out +// expands only the OMITTED dimension: a caller who pinned term_years=3 must +// get the three payment variants of a 3yr commitment, not all six combos. +func TestSearchRecommendationsFansOutOverPaymentOptionsOnly(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + TermYears: 3, + }) + + require.NoError(t, err) + assert.ElementsMatch(t, []searchCombo{ + {term: "3yr", payment: "all-upfront"}, + {term: "3yr", payment: "partial-upfront"}, + {term: "3yr", payment: "no-upfront"}, + }, client.combos()) +} + +// TestSearchRecommendationsFansOutOverTermsOnly is the mirror of the above: +// pinning payment_option must expand only the term dimension. +func TestSearchRecommendationsFansOutOverTermsOnly(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + PaymentOption: "all-upfront", + }) + + require.NoError(t, err) + assert.ElementsMatch(t, []searchCombo{ + {term: "1yr", payment: "all-upfront"}, + {term: "3yr", payment: "all-upfront"}, + }, client.combos()) +} + +// TestSearchRecommendationsSavingsPlansDoesNotFanOut proves the fan-out is +// scoped to reservations. A Savings Plans search already resolves to exactly +// one required (term, payment, lookback) triple via +// applySavingsPlansSearchDefaults, so fanning out would re-issue queries the +// caller never asked for and return duplicate offers. +func TestSearchRecommendationsSavingsPlansDoesNotFanOut(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{ + name: "aws", + services: []common.ServiceType{common.ServiceSavingsPlansCompute}, + recClient: client, + } + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "savings-plans-compute", + }) + + require.NoError(t, err) + assert.Equal(t, 1, client.calls) + assert.Equal(t, []searchCombo{{term: "1yr", payment: "no-upfront"}}, client.combos()) +} + +// TestSearchRecommendationsNonAWSDoesNotFanOut proves the fan-out is scoped +// to AWS. Azure derives term/payment from its own Advisor response rather +// than taking them as request filters, and GCP has no term/payment concept +// in its recommendations path, so fanning out there would issue identical +// repeat queries and duplicate every result. +func TestSearchRecommendationsNonAWSDoesNotFanOut(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "azure", services: []common.ServiceType{common.ServiceCompute}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "azure", + Service: "compute", + }) + + require.NoError(t, err) + assert.Equal(t, 1, client.calls) +} + +// TestSearchRecommendationsComboFailureFailsWholeSearch proves a failing +// combo is NOT skipped. Returning the five combos that succeeded is +// indistinguishable from "these are all your options" and would recreate the +// very defect the fan-out exists to fix, so the search fails loud and names +// the combo that broke. +func TestSearchRecommendationsComboFailureFailsWholeSearch(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{ + recs: []common.Recommendation{{Provider: common.ProviderAWS, ResourceType: "t4g.nano"}}, + errOnCall: 3, + errForCombo: errors.New("throttled"), + } + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "throttled") + assert.Contains(t, err.Error(), "term=1yr", "the error must name the combo that failed") + assert.Contains(t, err.Error(), "payment_option=no-upfront") + assert.Empty(t, result.Recommendations, "a partial result set must never be returned") +} + +// TestSearchRecommendationsEmptyResultIsEmptySliceNotNull pins the JSON shape +// of a no-results search: an empty slice serializes as [], where a nil slice +// serializes as null. Both showed up across services before this, so a +// client parsing the response had to handle two shapes for the same "nothing +// found" answer. +func TestSearchRecommendationsEmptyResultIsEmptySliceNotNull(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{recs: nil} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + }) + + require.NoError(t, err) + assert.Equal(t, 0, result.Count) + assert.NotNil(t, result.Recommendations) + assert.Empty(t, result.Recommendations) } // TestSearchRecommendationsMultipleInvalidFieldsJoinedInOneError is the From 12fe374a3bc5abaa90931ed28f79ed3cda0032bd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 23:01:24 +0200 Subject: [PATCH 75/83] fix(mcp): advertise term/payment enums and split the search test file Addresses two CodeRabbit findings on the search fan-out commit (d76aa7c00). The tool schema constrained only provider and lookback_period, so an MCP client could not discover the valid payment_option/term_years values from the schema and had to learn them by sending a value the tool rejects. Add the enum overrides, mirroring ValidatePaymentOption / ValidateTermYears. Those validators remain the enforcing guard: a client may send anything regardless of what the schema declares, so this is discoverability, not a new gate. TestSearchRecommendationsSchemaAdvertisesTermAndPaymentEnums pins both value sets through the real MCP protocol (ListTools), following the existing Azure partial-upfront precedent in this file, so the schema cannot drift from the validators unnoticed. Confirmed to fail when either override is removed. search_recommendations_test.go had grown to 651 lines, past the 500-line guideline in CLAUDE.md. Split the AWS reservation fan-out tests into search_recommendations_fanout_test.go along the existing topical seam, leaving 473 and 190 lines. The shared fakes stay in the original file and remain visible to both: same package, different file. --- mcp/server_test.go | 57 ++++++ mcp/tools/search_recommendations.go | 11 + .../search_recommendations_fanout_test.go | 190 ++++++++++++++++++ mcp/tools/search_recommendations_test.go | 178 ---------------- 4 files changed, 258 insertions(+), 178 deletions(-) create mode 100644 mcp/tools/search_recommendations_fanout_test.go diff --git a/mcp/server_test.go b/mcp/server_test.go index f2b44964c..a3562db82 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -221,3 +221,60 @@ func TestAzureComputeRIPurchaseSchemaExcludesPartialUpfront(t *testing.T) { assert.Contains(t, enum, "all-upfront") assert.Contains(t, enum, "no-upfront") } + +// TestSearchRecommendationsSchemaAdvertisesTermAndPaymentEnums pins that +// cudly_search_recommendations tells a client which term_years and +// payment_option values are valid, rather than leaving it to discover them by +// sending a value the tool rejects. ValidateTermYears / ValidatePaymentOption +// remain the enforcing guard (a client may send anything regardless of what +// the schema declares); this covers discoverability, and fails if the schema +// ever drifts from those validators. Drives the real MCP protocol (ListTools) +// so it asserts what an actual client sees. +func TestSearchRecommendationsSchemaAdvertisesTermAndPaymentEnums(t *testing.T) { + t.Parallel() + ctx := context.Background() + + server, err := NewServer("test") + require.NoError(t, err) + + clientTransport, serverTransport := gosdk.NewInMemoryTransports() + go func() { + _ = server.Run(ctx, serverTransport) + }() + + client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + defer session.Close() + + toolsList, err := session.ListTools(ctx, nil) + require.NoError(t, err) + + var searchTool *gosdk.Tool + for _, tl := range toolsList.Tools { + if tl.Name == "cudly_search_recommendations" { + searchTool = tl + break + } + } + require.NotNil(t, searchTool, "cudly_search_recommendations must be registered") + + schema, ok := searchTool.InputSchema.(map[string]any) + require.True(t, ok, "client-side InputSchema must be the default JSON marshaling (map[string]any)") + properties, ok := schema["properties"].(map[string]any) + require.True(t, ok) + + paymentOption, ok := properties["payment_option"].(map[string]any) + require.True(t, ok) + paymentEnum, ok := paymentOption["enum"].([]any) + require.True(t, ok, "payment_option must advertise its value set") + assert.ElementsMatch(t, []any{"all-upfront", "partial-upfront", "no-upfront"}, paymentEnum) + + termYears, ok := properties["term_years"].(map[string]any) + require.True(t, ok) + termEnum, ok := termYears["enum"].([]any) + require.True(t, ok, "term_years must advertise its value set") + // JSON numbers decode as float64 on the client side, so compare on the + // decoded values rather than the ints the server-side override used. + assert.ElementsMatch(t, []any{float64(1), float64(3)}, termEnum) +} diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index e76df2c4a..44fa62510 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -71,9 +71,20 @@ func (t *searchRecommendationsTool) Descriptor() Descriptor { } func (t *searchRecommendationsTool) Register(s *mcp.Server) error { + // Every constrained field advertises its value set in the schema, so an + // MCP client can discover the valid inputs without a round trip that + // fails validation. These mirror ValidatePaymentOption / ValidateTermYears, + // which stay the enforcing check: the schema is discoverability, not the + // guard, since a client may send anything regardless of what it declares. schema, err := BuildInputSchema[searchRecommendationsArgs](map[string]FieldOverride{ "provider": {Enum: []any{string(common.ProviderAWS), string(common.ProviderAzure), string(common.ProviderGCP)}}, "lookback_period": {Enum: []any{"7d", "30d", "60d"}}, + "payment_option": {Enum: []any{ + string(PaymentOptionAllUpfront), + string(PaymentOptionPartialUpfront), + string(PaymentOptionNoUpfront), + }}, + "term_years": {Enum: []any{int(TermOneYear), int(TermThreeYear)}}, }) if err != nil { return err diff --git a/mcp/tools/search_recommendations_fanout_test.go b/mcp/tools/search_recommendations_fanout_test.go new file mode 100644 index 000000000..da05c931d --- /dev/null +++ b/mcp/tools/search_recommendations_fanout_test.go @@ -0,0 +1,190 @@ +package tools + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" +) + +// TestSearchRecommendationsFansOutAllCombosWhenTermAndPaymentOmitted is the +// regression guard for the defect where "omitted means search all" was false +// for reservations. The tool sent Cost Explorer an empty +// TermInYears/PaymentOption, AWS silently applied its own 1yr/all-upfront +// default, and the caller got ONE of six purchasable offers while the tool's +// own schema claimed it had searched them all -- on a real account that hid +// the best offer, a 3yr/all-upfront saving 63% where the returned +// 1yr/all-upfront saved 40% on the identical instance. +func TestSearchRecommendationsFansOutAllCombosWhenTermAndPaymentOmitted(t *testing.T) { + t.Parallel() + recs := []common.Recommendation{{Provider: common.ProviderAWS, ResourceType: "t4g.nano", Count: 1}} + client := &fakeRecommendationsClient{recs: recs} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + Region: "eu-west-1", + }) + + require.NoError(t, err) + assert.ElementsMatch(t, []searchCombo{ + {term: "1yr", payment: "all-upfront"}, + {term: "1yr", payment: "partial-upfront"}, + {term: "1yr", payment: "no-upfront"}, + {term: "3yr", payment: "all-upfront"}, + {term: "3yr", payment: "partial-upfront"}, + {term: "3yr", payment: "no-upfront"}, + }, client.combos(), "every purchasable term/payment offer must be searched") + // Every combo's recommendations reach the caller, so the full menu is + // returned rather than one arbitrary cell of it. + assert.Equal(t, 6, result.Count) + assert.Len(t, result.Recommendations, 6) + // Shared fields still ride along on each fanned-out call. + for i, p := range client.allParams { + assert.Equalf(t, "eu-west-1", p.Region, "call %d lost the region filter", i) + } +} + +// TestSearchRecommendationsFansOutOverPaymentOptionsOnly proves the fan-out +// expands only the OMITTED dimension: a caller who pinned term_years=3 must +// get the three payment variants of a 3yr commitment, not all six combos. +func TestSearchRecommendationsFansOutOverPaymentOptionsOnly(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + TermYears: 3, + }) + + require.NoError(t, err) + assert.ElementsMatch(t, []searchCombo{ + {term: "3yr", payment: "all-upfront"}, + {term: "3yr", payment: "partial-upfront"}, + {term: "3yr", payment: "no-upfront"}, + }, client.combos()) +} + +// TestSearchRecommendationsFansOutOverTermsOnly is the mirror of the above: +// pinning payment_option must expand only the term dimension. +func TestSearchRecommendationsFansOutOverTermsOnly(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + PaymentOption: "all-upfront", + }) + + require.NoError(t, err) + assert.ElementsMatch(t, []searchCombo{ + {term: "1yr", payment: "all-upfront"}, + {term: "3yr", payment: "all-upfront"}, + }, client.combos()) +} + +// TestSearchRecommendationsSavingsPlansDoesNotFanOut proves the fan-out is +// scoped to reservations. A Savings Plans search already resolves to exactly +// one required (term, payment, lookback) triple via +// applySavingsPlansSearchDefaults, so fanning out would re-issue queries the +// caller never asked for and return duplicate offers. +func TestSearchRecommendationsSavingsPlansDoesNotFanOut(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{ + name: "aws", + services: []common.ServiceType{common.ServiceSavingsPlansCompute}, + recClient: client, + } + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "savings-plans-compute", + }) + + require.NoError(t, err) + assert.Equal(t, 1, client.calls) + assert.Equal(t, []searchCombo{{term: "1yr", payment: "no-upfront"}}, client.combos()) +} + +// TestSearchRecommendationsNonAWSDoesNotFanOut proves the fan-out is scoped +// to AWS. Azure derives term/payment from its own Advisor response rather +// than taking them as request filters, and GCP has no term/payment concept +// in its recommendations path, so fanning out there would issue identical +// repeat queries and duplicate every result. +func TestSearchRecommendationsNonAWSDoesNotFanOut(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: "azure", services: []common.ServiceType{common.ServiceCompute}, recClient: client} + tool := newTestSearchTool(fp) + + _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "azure", + Service: "compute", + }) + + require.NoError(t, err) + assert.Equal(t, 1, client.calls) +} + +// TestSearchRecommendationsComboFailureFailsWholeSearch proves a failing +// combo is NOT skipped. Returning the five combos that succeeded is +// indistinguishable from "these are all your options" and would recreate the +// very defect the fan-out exists to fix, so the search fails loud and names +// the combo that broke. +func TestSearchRecommendationsComboFailureFailsWholeSearch(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{ + recs: []common.Recommendation{{Provider: common.ProviderAWS, ResourceType: "t4g.nano"}}, + errOnCall: 3, + errForCombo: errors.New("throttled"), + } + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "throttled") + assert.Contains(t, err.Error(), "term=1yr", "the error must name the combo that failed") + assert.Contains(t, err.Error(), "payment_option=no-upfront") + assert.Empty(t, result.Recommendations, "a partial result set must never be returned") +} + +// TestSearchRecommendationsEmptyResultIsEmptySliceNotNull pins the JSON shape +// of a no-results search: an empty slice serializes as [], where a nil slice +// serializes as null. Both showed up across services before this, so a +// client parsing the response had to handle two shapes for the same "nothing +// found" answer. +func TestSearchRecommendationsEmptyResultIsEmptySliceNotNull(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{recs: nil} + fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} + tool := newTestSearchTool(fp) + + _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ + Provider: "aws", + Service: "ec2", + }) + + require.NoError(t, err) + assert.Equal(t, 0, result.Count) + assert.NotNil(t, result.Recommendations) + assert.Empty(t, result.Recommendations) +} diff --git a/mcp/tools/search_recommendations_test.go b/mcp/tools/search_recommendations_test.go index 6f07759ca..419eb62a8 100644 --- a/mcp/tools/search_recommendations_test.go +++ b/mcp/tools/search_recommendations_test.go @@ -379,184 +379,6 @@ func TestSearchRecommendationsEC2NoDefaultsInjected(t *testing.T) { assert.Greater(t, client.calls, 1, "an EC2 search must not collapse to one narrowed combo") } -// TestSearchRecommendationsFansOutAllCombosWhenTermAndPaymentOmitted is the -// regression guard for the defect where "omitted means search all" was false -// for reservations. The tool sent Cost Explorer an empty -// TermInYears/PaymentOption, AWS silently applied its own 1yr/all-upfront -// default, and the caller got ONE of six purchasable offers while the tool's -// own schema claimed it had searched them all -- on a real account that hid -// the best offer, a 3yr/all-upfront saving 63% where the returned -// 1yr/all-upfront saved 40% on the identical instance. -func TestSearchRecommendationsFansOutAllCombosWhenTermAndPaymentOmitted(t *testing.T) { - t.Parallel() - recs := []common.Recommendation{{Provider: common.ProviderAWS, ResourceType: "t4g.nano", Count: 1}} - client := &fakeRecommendationsClient{recs: recs} - fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} - tool := newTestSearchTool(fp) - - _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ - Provider: "aws", - Service: "ec2", - Region: "eu-west-1", - }) - - require.NoError(t, err) - assert.ElementsMatch(t, []searchCombo{ - {term: "1yr", payment: "all-upfront"}, - {term: "1yr", payment: "partial-upfront"}, - {term: "1yr", payment: "no-upfront"}, - {term: "3yr", payment: "all-upfront"}, - {term: "3yr", payment: "partial-upfront"}, - {term: "3yr", payment: "no-upfront"}, - }, client.combos(), "every purchasable term/payment offer must be searched") - // Every combo's recommendations reach the caller, so the full menu is - // returned rather than one arbitrary cell of it. - assert.Equal(t, 6, result.Count) - assert.Len(t, result.Recommendations, 6) - // Shared fields still ride along on each fanned-out call. - for i, p := range client.allParams { - assert.Equalf(t, "eu-west-1", p.Region, "call %d lost the region filter", i) - } -} - -// TestSearchRecommendationsFansOutOverPaymentOptionsOnly proves the fan-out -// expands only the OMITTED dimension: a caller who pinned term_years=3 must -// get the three payment variants of a 3yr commitment, not all six combos. -func TestSearchRecommendationsFansOutOverPaymentOptionsOnly(t *testing.T) { - t.Parallel() - client := &fakeRecommendationsClient{} - fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} - tool := newTestSearchTool(fp) - - _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ - Provider: "aws", - Service: "ec2", - TermYears: 3, - }) - - require.NoError(t, err) - assert.ElementsMatch(t, []searchCombo{ - {term: "3yr", payment: "all-upfront"}, - {term: "3yr", payment: "partial-upfront"}, - {term: "3yr", payment: "no-upfront"}, - }, client.combos()) -} - -// TestSearchRecommendationsFansOutOverTermsOnly is the mirror of the above: -// pinning payment_option must expand only the term dimension. -func TestSearchRecommendationsFansOutOverTermsOnly(t *testing.T) { - t.Parallel() - client := &fakeRecommendationsClient{} - fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} - tool := newTestSearchTool(fp) - - _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ - Provider: "aws", - Service: "ec2", - PaymentOption: "all-upfront", - }) - - require.NoError(t, err) - assert.ElementsMatch(t, []searchCombo{ - {term: "1yr", payment: "all-upfront"}, - {term: "3yr", payment: "all-upfront"}, - }, client.combos()) -} - -// TestSearchRecommendationsSavingsPlansDoesNotFanOut proves the fan-out is -// scoped to reservations. A Savings Plans search already resolves to exactly -// one required (term, payment, lookback) triple via -// applySavingsPlansSearchDefaults, so fanning out would re-issue queries the -// caller never asked for and return duplicate offers. -func TestSearchRecommendationsSavingsPlansDoesNotFanOut(t *testing.T) { - t.Parallel() - client := &fakeRecommendationsClient{} - fp := &fakeProvider{ - name: "aws", - services: []common.ServiceType{common.ServiceSavingsPlansCompute}, - recClient: client, - } - tool := newTestSearchTool(fp) - - _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ - Provider: "aws", - Service: "savings-plans-compute", - }) - - require.NoError(t, err) - assert.Equal(t, 1, client.calls) - assert.Equal(t, []searchCombo{{term: "1yr", payment: "no-upfront"}}, client.combos()) -} - -// TestSearchRecommendationsNonAWSDoesNotFanOut proves the fan-out is scoped -// to AWS. Azure derives term/payment from its own Advisor response rather -// than taking them as request filters, and GCP has no term/payment concept -// in its recommendations path, so fanning out there would issue identical -// repeat queries and duplicate every result. -func TestSearchRecommendationsNonAWSDoesNotFanOut(t *testing.T) { - t.Parallel() - client := &fakeRecommendationsClient{} - fp := &fakeProvider{name: "azure", services: []common.ServiceType{common.ServiceCompute}, recClient: client} - tool := newTestSearchTool(fp) - - _, _, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ - Provider: "azure", - Service: "compute", - }) - - require.NoError(t, err) - assert.Equal(t, 1, client.calls) -} - -// TestSearchRecommendationsComboFailureFailsWholeSearch proves a failing -// combo is NOT skipped. Returning the five combos that succeeded is -// indistinguishable from "these are all your options" and would recreate the -// very defect the fan-out exists to fix, so the search fails loud and names -// the combo that broke. -func TestSearchRecommendationsComboFailureFailsWholeSearch(t *testing.T) { - t.Parallel() - client := &fakeRecommendationsClient{ - recs: []common.Recommendation{{Provider: common.ProviderAWS, ResourceType: "t4g.nano"}}, - errOnCall: 3, - errForCombo: errors.New("throttled"), - } - fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} - tool := newTestSearchTool(fp) - - _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ - Provider: "aws", - Service: "ec2", - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "throttled") - assert.Contains(t, err.Error(), "term=1yr", "the error must name the combo that failed") - assert.Contains(t, err.Error(), "payment_option=no-upfront") - assert.Empty(t, result.Recommendations, "a partial result set must never be returned") -} - -// TestSearchRecommendationsEmptyResultIsEmptySliceNotNull pins the JSON shape -// of a no-results search: an empty slice serializes as [], where a nil slice -// serializes as null. Both showed up across services before this, so a -// client parsing the response had to handle two shapes for the same "nothing -// found" answer. -func TestSearchRecommendationsEmptyResultIsEmptySliceNotNull(t *testing.T) { - t.Parallel() - client := &fakeRecommendationsClient{recs: nil} - fp := &fakeProvider{name: "aws", services: []common.ServiceType{common.ServiceEC2}, recClient: client} - tool := newTestSearchTool(fp) - - _, result, err := tool.handle(context.Background(), nil, searchRecommendationsArgs{ - Provider: "aws", - Service: "ec2", - }) - - require.NoError(t, err) - assert.Equal(t, 0, result.Count) - assert.NotNil(t, result.Recommendations) - assert.Empty(t, result.Recommendations) -} - // TestSearchRecommendationsMultipleInvalidFieldsJoinedInOneError is the // validate-all-at-once safety net (issue #1506 change 2): a request with // several invalid fields must name all of them in one returned error instead From 85a86ac542edbce0dceae8f1df73df98ba045424 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 23:06:25 +0200 Subject: [PATCH 76/83] fix(mcp): omit effective_date when the provider reported no timestamp common.PurchaseResult.Timestamp is a plain time.Time that not every provider client populates. PurchaseResponse.EffectiveDate was a plain string set from result.Timestamp.Format(time.RFC3339), and formatting the zero time.Time yields the literal "0001-01-01T00:00:00Z" rather than "", so the `omitempty` tag could never drop it. Every such response therefore advertised a real-looking commitment start date in the year 1: a fabricated value presented as real, on a field a caller may key billing or renewal reminders off. Make EffectiveDate a *string and populate it through rfc3339OrNil, which returns nil for the zero time. This is the time analog of the existing nonZeroCostPtr, applied for the same reason the cost fields already use pointers: keep "not known" distinguishable from a real value instead of fabricating one. Adds two regression tests. The guard asserts against the marshaled JSON, not just the Go field, because the JSON payload is what actually crosses the MCP boundary to the caller; it fails on the pre-fix code and passes after. The companion test pins that suppressing the zero value does not suppress a genuine timestamp. --- mcp/tools/purchase.go | 28 ++++++++++++++-- mcp/tools/purchase_test.go | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index 2e178d7cb..a077e98c4 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -232,6 +232,14 @@ func ResolveDryRunConfirm(dryRun, confirm *bool) (effectiveDryRun, effectiveConf // it never actually priced. A pointer that's nil (and omitted from the JSON // payload entirely) when no real value exists lets a caller tell "unknown" // apart from "confirmed zero" (feedback_nullable_not_zero). +// +// EffectiveDate is a pointer for the same reason, and it is not a +// hypothetical concern: `omitempty` on a string only drops "", so a +// PurchaseResult whose Timestamp was never populated formatted its zero +// time.Time to the literal "0001-01-01T00:00:00Z" and shipped that as the +// commitment's start date. That is a fabricated date presented as real, on +// a field a caller may key billing or renewal reminders off. Nil (and +// omitted) when the provider reported no timestamp. type PurchaseResponse struct { Success bool `json:"success"` DryRun bool `json:"dry_run"` @@ -240,7 +248,7 @@ type PurchaseResponse struct { OnDemandCost *float64 `json:"on_demand_cost,omitempty"` EstimatedSavings *float64 `json:"estimated_savings,omitempty"` SavingsPercentage *float64 `json:"savings_percentage,omitempty"` - EffectiveDate string `json:"effective_date,omitempty"` + EffectiveDate *string `json:"effective_date,omitempty"` TermYears int `json:"term_years,omitempty"` Error string `json:"error,omitempty"` @@ -320,6 +328,22 @@ func nonZeroCostPtr(v float64) *float64 { return &v } +// rfc3339OrNil formats t as RFC3339, or returns nil when t is the zero +// time.Time. This is the time analog of nonZeroCostPtr and exists for the +// same reason: common.PurchaseResult.Timestamp is a plain time.Time that not +// every provider client populates, and formatting an unset one yields the +// literal "0001-01-01T00:00:00Z" rather than an empty string, so +// `omitempty` on a string field could never drop it. Returning nil keeps +// "the provider did not tell us when this starts" distinguishable from a +// real date (feedback_nullable_not_zero). +func rfc3339OrNil(t time.Time) *string { + if t.IsZero() { + return nil + } + s := t.Format(time.RFC3339) + return &s +} + // idempotencyKeyFor derives a stable per-request key from every field that // identifies what is being bought: provider, region, service, resource type, // count, term, payment option, plus every service-specific dimension held in @@ -575,7 +599,7 @@ func ExecutePurchase(ctx context.Context, req PurchaseRequest) (*PurchaseRespons OnDemandCost: nonZeroCostPtr(rec.OnDemandCost), EstimatedSavings: nonZeroCostPtr(rec.EstimatedSavings), SavingsPercentage: nonZeroCostPtr(rec.SavingsPercentage), - EffectiveDate: result.Timestamp.Format(time.RFC3339), + EffectiveDate: rfc3339OrNil(result.Timestamp), TermYears: termYearsFromRecommendationTerm(rec.Term), } if result.Error != nil { diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 48559ffa6..755e04f4e 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -8,6 +8,7 @@ import ( "log" "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -411,6 +412,71 @@ func TestExecutePurchaseRealPurchaseCallsProviderWithMCPSource(t *testing.T) { assert.False(t, resp.DryRun) } +// TestExecutePurchaseUnsetTimestampOmitsEffectiveDate is the regression guard +// for the fabricated start date found in review. common.PurchaseResult's +// Timestamp is a plain time.Time that not every provider client populates, +// and EffectiveDate used to be a plain string set from +// result.Timestamp.Format(time.RFC3339). Formatting the zero time.Time +// yields the literal "0001-01-01T00:00:00Z" rather than "", so `omitempty` +// could never drop it and every such response advertised a real-looking +// commitment start date in the year 1 -- a value the provider never +// reported, on a field a caller may key billing or renewal reminders off. +// +// The assertion is made against the marshaled JSON, not just the Go field, +// because the JSON payload is what actually crosses the MCP boundary to the +// caller. +func TestExecutePurchaseUnsetTimestampOmitsEffectiveDate(t *testing.T) { + t.Parallel() + // Timestamp deliberately left unset, exactly as a provider client that + // never populates it leaves it. + fake := &fakeServiceClient{ + purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "ri-no-timestamp"}, + } + + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", + Recommendation: testRecommendation(), + DryRun: false, + Confirm: true, CredentialScope: "test-scope", + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Nil(t, resp.EffectiveDate, + "an unset provider timestamp must stay unknown, not become a formatted zero time") + + payload, err := json.Marshal(resp) + require.NoError(t, err) + assert.NotContains(t, string(payload), "0001-01-01", + "the zero time.Time must never reach the caller as a start date: %s", payload) + assert.NotContains(t, string(payload), "effective_date", + "effective_date must be omitted entirely when the provider reported none: %s", payload) +} + +// TestExecutePurchaseRealTimestampIsReported is the other half of the guard +// above: suppressing the zero value must not suppress a genuine one. +func TestExecutePurchaseRealTimestampIsReported(t *testing.T) { + t.Parallel() + stamp := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + fake := &fakeServiceClient{ + purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "ri-stamped", Timestamp: stamp}, + } + + resp, err := ExecutePurchase(context.Background(), PurchaseRequest{ + Region: "us-east-1", + Recommendation: testRecommendation(), + DryRun: false, + Confirm: true, CredentialScope: "test-scope", + ResolveClient: func(_ context.Context) (provider.ServiceClient, error) { return fake, nil }, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.EffectiveDate, "a populated provider timestamp must still be reported") + assert.Equal(t, stamp.Format(time.RFC3339), *resp.EffectiveDate) +} + // TestExecutePurchaseSameRequestDerivesSameToken proves idempotencyKeyFor // (and therefore the derived token) is deterministic for the same // identifying fields, so a retried call with identical arguments dedupes at From 3a65136ebbf43b25d42e80bf4c7be02c4c88d57c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 23:06:42 +0200 Subject: [PATCH 77/83] test(mcp): guard content index and type assertion in the binary smoke test result.Content[0].(*gosdk.TextContent) panics on an empty Content slice or a non-text first block. A panic aborts the whole package's test run rather than failing this one assertion, so a regression in the tool's error shape would surface as an unrelated-looking crash instead of a readable failure naming the actual problem. Check the slice is non-empty and use the comma-ok form, reporting the concrete type when the assertion fails. --- cmd/cudly-mcp/main_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/cudly-mcp/main_test.go b/cmd/cudly-mcp/main_test.go index 6b7c954dd..863ce0626 100644 --- a/cmd/cudly-mcp/main_test.go +++ b/cmd/cudly-mcp/main_test.go @@ -98,7 +98,14 @@ func TestRealPurchasePastProviderRegistration(t *testing.T) { require.NoError(t, err, "CallTool itself must not return a transport-level error") require.True(t, result.IsError, "a failed real purchase must surface as a tool error, not a transport error") - text := result.Content[0].(*gosdk.TextContent).Text + // Guard both the index and the type assertion: a bare + // result.Content[0].(*gosdk.TextContent) panics on an empty Content slice + // or a non-text block, which aborts the whole package's test run instead + // of failing this one assertion readably. + require.NotEmpty(t, result.Content, "tool error result must carry at least one content block") + textContent, ok := result.Content[0].(*gosdk.TextContent) + require.True(t, ok, "first content block must be text, got %T", result.Content[0]) + text := textContent.Text assert.NotContains(t, strings.ToLower(text), "not registered", "provider must be registered for the cudly-mcp binary: got %q", text) // providers/aws/provider.go's GetServiceClient (via AWSProvider.IsConfigured) From f8ae7017e88d4fd74c1a0279a53bb17c65dcd4ef Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 23:07:06 +0200 Subject: [PATCH 78/83] docs(mcp): explain why the GCP CUD tool has no ambient scope fallback Review flagged that gcp_computeengine_cud.go passes no environment fallback to CredentialScope, unlike the AWS ("AWS_PROFILE") and Azure ("AZURE_SUBSCRIPTION_ID") tools, so omitting gcp_project_id makes requireCredentialScope refuse every real GCP CUD purchase. The asymmetry is deliberate and must stay. CredentialScope's contract is that its fallback names the same variable the provider factory itself consults, so that naming an account explicitly and letting it resolve ambiently derive the same idempotency token. GCP has no such variable: resolveGCPProjectID reads only config.GCPProjectID and the deprecated config.Profile, and when both are empty NewProvider falls through to getDefaultProject, which picks the first ACTIVE project returned by a paginated ListProjects. Adding GOOGLE_CLOUD_PROJECT to the CredentialScope call alone would make the token lie: the scope would read that variable while the purchase still landed in whatever project getDefaultProject resolved, so the same target reached two ways would derive two different tokens and double-buy. That is precisely the hazard requireCredentialScope was added to close. Teaching the factory to read it too would fix the divergence but change project selection for every other consumer of providers/gcp (CLI, web, scheduler), and GOOGLE_CLOUD_PROJECT conventionally names the project a process runs in rather than the one it should buy for, so on a hosted runtime it would silently redirect purchases. Document the reasoning at the call site so it is not "fixed" later, and correct the gcp_project_id schema description, which still told the model the field was optional. It is optional for a dry_run preview and required for a real purchase, which is what made the refusal surprising in the first place. --- mcp/tools/gcp_computeengine_cud.go | 38 ++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index 44e032111..7aa9fe47c 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -37,7 +37,7 @@ type gcpComputeEngineCUDPurchaseArgs struct { 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"` TermYears int `json:"term_years" jsonschema:"commitment length in years"` - GCPProjectID string `json:"gcp_project_id,omitempty" jsonschema:"GCP project ID override; default uses ambient project"` + 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"` DryRun *bool `json:"dry_run,omitempty" jsonschema:"preview only, no purchase; defaults to true"` Confirm *bool `json:"confirm,omitempty" jsonschema:"required (with dry_run=false) to execute a real purchase; defaults to false"` 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"` @@ -91,12 +91,36 @@ func (t *gcpComputeEngineCUDPurchaseTool) handle(ctx context.Context, _ *mcp.Cal } resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: region, - Recommendation: rec, - DryRun: dryRun, - Confirm: confirm, - ResolveClient: t.resolveClient(args, region), - Nonce: args.IdempotencyNonce, + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: t.resolveClient(args, region), + Nonce: args.IdempotencyNonce, + // No ambient environment fallback here, deliberately, and unlike the + // AWS ("AWS_PROFILE") and Azure ("AZURE_SUBSCRIPTION_ID") tools. Do + // not "fix" this asymmetry by adding one: CredentialScope's contract + // is that its fallback names the SAME variable the provider factory + // itself consults, so that naming an account explicitly and letting + // it resolve ambiently derive the same idempotency token. GCP has no + // such variable. providers/gcp/provider.go's resolveGCPProjectID + // reads only config.GCPProjectID and the deprecated config.Profile, + // and when both are empty NewProvider falls through to + // getDefaultProject, which picks the first ACTIVE project returned by + // cloudresourcemanager's paginated ListProjects. + // + // Adding e.g. GOOGLE_CLOUD_PROJECT to this call alone would make the + // token LIE: the scope would read that variable while the purchase + // still landed in whatever project getDefaultProject resolved, so the + // same target reached two ways would derive two different tokens and + // double-buy. That is exactly the hazard requireCredentialScope + // exists to close. Teaching the factory to read it too would fix the + // divergence but change project selection for every other consumer of + // providers/gcp (CLI, web, scheduler), and GOOGLE_CLOUD_PROJECT + // conventionally names the project a process RUNS IN, not the one it + // should buy for, so on a hosted runtime that silently redirects + // purchases. Requiring gcp_project_id explicitly is the safe reading: + // it fails closed with a message naming the argument to pass. CredentialScope: CredentialScope(args.GCPProjectID), }) if err != nil { From c19cff40413cf412e60c5f61f03440dbc0bce947 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 23:12:24 +0200 Subject: [PATCH 79/83] fix(mcp): make the GCP credential-scope refusal actionable Real GCP CUD purchases are refused unless gcp_project_id is supplied, which is correct, but the refusal read: refusing real purchase: the target account could not be determined, so pass gcp_project_id explicitly (or set ). Two problems. It named an EMPTY environment variable, because GCP has no ambient scope source for CredentialScope to fall back to. And "could not be determined" implies an ambient source exists, sending a GCP caller looking for a variable nothing reads. Keeping the refusal strict, rather than inventing a GCP env fallback, is deliberate: - Nothing in providers/gcp reads GOOGLE_CLOUD_PROJECT or CLOUDSDK_CORE_PROJECT, so adding one would invent a convention the provider does not honor. - It would also be unsafe. With no project configured the provider falls back to getDefaultProject, which returns the FIRST ACTIVE PROJECT in the caller's ListProjects response -- an artifact of IAM visibility and API ordering, not a project anyone declared. An env-supplied scope could name project A while the commitment landed in project B, making the idempotency token assert an account it never touched. That is the aliasing hazard requireCredentialScope exists to remove, in a worse form. Providers now carry a credentialScopeSource describing their tool argument and their ambient variable, if any. GCP has none, so it gets a plain "gcp_project_id is required" message that explains why there is no fallback; AWS and Azure keep naming AWS_PROFILE and AZURE_SUBSCRIPTION_ID so a caller who set the variable is not told to pass an argument they do not need. The doc comment records the reasoning so the missing fallback is not "fixed" later. mcp/README.md states the contract alongside the existing scope rules. TestRequireCredentialScopeGCPMessageIsActionable pins that GCP says "is required" and never "could not be determined", and that AWS and Azure still name their variables. Verified failing before this change. The schema/description half of this finding, and the sibling effective_date and test-assertion findings, are already fixed on this branch by f8ae7017e, 3a65136eb and 85a86ac54; this commit deliberately does not duplicate them. Their rfc3339OrNil returns *string, which distinguishes "unknown" better than the string version drafted here, so that one is kept and mine dropped. --- mcp/README.md | 2 +- mcp/tools/purchase.go | 76 ++++++++++++++++++++++++++++++-------- mcp/tools/purchase_test.go | 56 ++++++++++++++++++++++++++-- 3 files changed, 114 insertions(+), 20 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index a3201cbd0..e0bb781c7 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -99,7 +99,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. - A real purchase requires **both** `dry_run=false` **and** `confirm=true`. `dry_run=false` with `confirm=false` (or vice versa) is refused with a structured error, not silently downgraded to a preview or silently ignored. - 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 requires the **target account to be named**: `aws_profile` (AWS), `azure_subscription_id` (Azure), or `gcp_project_id` (GCP), either as the tool argument or via the matching environment variable the provider itself reads (e.g. `AWS_PROFILE`, `AZURE_SUBSCRIPTION_ID`). 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. +- 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. - Provider/SDK failures surface their full error text back to the caller; nothing is swallowed. diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index a077e98c4..3241e73a2 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -124,22 +124,55 @@ func CredentialScope(explicit string, envVars ...string) string { return "" } -// credentialScopeArg names the tool argument that supplies the credential -// scope for a provider, so requireCredentialScope's refusal can tell the -// caller exactly which argument to pass. Unknown providers are an explicit -// error rather than a generic message: a provider added without a scope -// argument here would otherwise get a refusal it cannot act on -// (feedback_no_silent_fallbacks). -func credentialScopeArg(p common.ProviderType) (string, error) { +// credentialScopeSource describes where a provider's credential scope can come +// from: the tool argument that supplies it, and the ambient environment +// variable that can supply it instead, if the provider has one. +// +// envVar must name a variable the matching provider factory ITSELF consults, +// so a scope inherited from the environment always identifies the account the +// purchase really lands in. A variable the factory ignores would make the +// token positively assert the wrong account -- worse than having no fallback, +// which is why gcp has none (see credentialScopeSourceFor). +type credentialScopeSource struct { + arg string + envVar string +} + +// credentialScopeSourceFor returns how p's credential scope may be supplied, +// so requireCredentialScope's refusal can name exactly what the caller has to +// do. Unknown providers are an explicit error rather than a generic message: a +// provider added without an entry here would otherwise get a refusal it cannot +// act on (feedback_no_silent_fallbacks). +// +// GCP deliberately has NO environment fallback, and this is a decision rather +// than an omission -- do not "fix" it by adding one: +// +// - Nothing in providers/gcp reads GOOGLE_CLOUD_PROJECT or +// CLOUDSDK_CORE_PROJECT, so adding either here would invent a convention +// the provider does not honor. +// - Worse, it would be unsafe. When no project is configured, the provider +// falls back to getDefaultProject (providers/gcp/provider.go), which +// returns the FIRST ACTIVE PROJECT in the caller's ListProjects response +// -- an artifact of IAM visibility and API ordering, not a project anyone +// declared. An env-supplied scope could therefore name project A while the +// purchase landed in project B, making the idempotency token assert an +// account it never touched. That is the aliasing hazard +// requireCredentialScope exists to remove, in a worse form. +// +// So a real GCP purchase must name gcp_project_id. That is a stated contract +// (documented in mcp/README.md and in the tool's own schema), not an accident +// of the gate: "whichever project happens to be listed first" is not a +// defensible default for spending money. +func credentialScopeSourceFor(p common.ProviderType) (credentialScopeSource, error) { switch p { case common.ProviderAWS: - return "aws_profile", nil + return credentialScopeSource{arg: "aws_profile", envVar: "AWS_PROFILE"}, nil case common.ProviderAzure: - return "azure_subscription_id", nil + return credentialScopeSource{arg: "azure_subscription_id", envVar: "AZURE_SUBSCRIPTION_ID"}, nil case common.ProviderGCP: - return "gcp_project_id", nil + return credentialScopeSource{arg: "gcp_project_id"}, nil default: - return "", fmt.Errorf("internal error: no credential-scope argument defined for provider %q", p) + return credentialScopeSource{}, fmt.Errorf("internal error: no credential-scope source defined for provider %q", p) } } @@ -184,13 +217,26 @@ func requireCredentialScope(p common.ProviderType, scope string) error { if strings.TrimSpace(scope) != "" { return nil } - arg, err := credentialScopeArg(p) + src, err := credentialScopeSourceFor(p) if err != nil { return err } - return fmt.Errorf("refusing real purchase: the target account could not be determined, so pass %s explicitly. "+ - "Leaving it to ambient credentials derives a different idempotency token than naming the same account "+ - "explicitly, so a retry would not dedupe and could purchase twice. Dry runs do not require it", arg) + // Providers with an ambient fallback get a "could not be determined" + // message naming both ways to supply it. GCP has none by design, so + // telling a caller their environment failed to provide something it never + // could would send them debugging the wrong thing; it gets a plain + // "required" instead. + if src.envVar == "" { + return fmt.Errorf("refusing real purchase: %s is required for a real %s purchase and was not supplied. "+ + "There is no environment fallback: with no project named, the provider would spend in whichever "+ + "project happens to be listed first for your credentials, and the idempotency token could not "+ + "identify the account, so a retry might not dedupe and could purchase twice. Dry runs do not require it", + src.arg, p) + } + return fmt.Errorf("refusing real purchase: the target account could not be determined, so pass %s explicitly "+ + "(or set %s). Leaving it to ambient credentials derives a different idempotency token than naming the "+ + "same account explicitly, so a retry would not dedupe and could purchase twice. Dry runs do not require it", + src.arg, src.envVar) } // ResolveDryRunConfirm applies the dry_run=true / confirm=false defaults to a diff --git a/mcp/tools/purchase_test.go b/mcp/tools/purchase_test.go index 755e04f4e..6843d3139 100644 --- a/mcp/tools/purchase_test.go +++ b/mcp/tools/purchase_test.go @@ -1129,11 +1129,59 @@ func TestCredentialScopeArgNamesEveryProvider(t *testing.T) { common.ProviderAzure: "azure_subscription_id", common.ProviderGCP: "gcp_project_id", } { - got, err := credentialScopeArg(prov) - require.NoErrorf(t, err, "provider %q must have a scope argument", prov) - assert.Equal(t, want, got) + got, err := credentialScopeSourceFor(prov) + require.NoErrorf(t, err, "provider %q must have a scope source", prov) + assert.Equal(t, want, got.arg) } - _, err := credentialScopeArg(common.ProviderType("nimbus")) + _, err := credentialScopeSourceFor(common.ProviderType("nimbus")) require.Error(t, err, "an unknown provider must fail loud rather than emit an unactionable refusal") } + +// TestRequireCredentialScopeGCPMessageIsActionable pins the GCP contract found +// by review after the fail-closed gate landed. +// +// gcp_computeengine_cud.go calls CredentialScope(args.GCPProjectID) with no +// environment fallback, so omitting gcp_project_id ALWAYS yields "" and the +// gate refuses every real GCP purchase. That is the intended contract, not a +// bug -- providers/gcp reads no project environment variable, and with none +// configured it falls back to getDefaultProject, i.e. the first ACTIVE project +// in the caller's ListProjects response. Spending money in "whichever project +// happened to be listed first" is not a defensible default, and an +// env-supplied scope would be worse still: it could name project A while the +// purchase landed in project B, making the token assert an account it never +// touched. +// +// Since the refusal is the contract, the message has to be actionable. Telling +// a GCP caller their account "could not be determined" would send them looking +// for an environment variable that does not exist, so GCP gets a plain +// "required" instead. +func TestRequireCredentialScopeGCPMessageIsActionable(t *testing.T) { + t.Parallel() + + gcpErr := requireCredentialScope(common.ProviderGCP, "") + require.Error(t, gcpErr) + assert.Contains(t, gcpErr.Error(), "gcp_project_id", "the refusal must name the argument to pass") + assert.Contains(t, gcpErr.Error(), "is required", + "GCP has no environment fallback, so the message must say the argument is required") + assert.NotContains(t, gcpErr.Error(), "could not be determined", + "that phrasing implies an ambient source exists, sending a GCP caller after a variable nothing reads") + + // Providers that DO have a fallback must still name it, so a caller who + // set the environment variable is not told to pass an argument they do + // not need. + for _, tc := range []struct { + provider common.ProviderType + arg string + envVar string + }{ + {common.ProviderAWS, "aws_profile", "AWS_PROFILE"}, + {common.ProviderAzure, "azure_subscription_id", "AZURE_SUBSCRIPTION_ID"}, + } { + err := requireCredentialScope(tc.provider, "") + require.Errorf(t, err, "provider %q", tc.provider) + assert.Containsf(t, err.Error(), tc.arg, "provider %q must name its argument", tc.provider) + assert.Containsf(t, err.Error(), tc.envVar, + "provider %q has an ambient fallback and must name it", tc.provider) + } +} From 44b6094929813abc1c5234df2e1f4a7e7ebdd47c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 28 Jul 2026 01:01:18 +0200 Subject: [PATCH 80/83] fix(mcp): stop ignored region and untrimmed credentials forking purchase identity Addresses the two unresolved CodeRabbit threads on #1495. Both let an input that should not affect purchase identity change the idempotency token, and a forked token means ClientToken dedupe misses and the purchase runs twice. region is documented as "ignored for account-level plan types", but for Compute/SageMaker/Database it was still assigned to region and rec.Region and so reached idempotencyKeyFor. Buying a $10/hr Compute SP once without region and re-issuing the identical call with region="eu-west-1" (a self-correction, or a retry that fills the field in) derived two different tokens and bought a second plan. Pin the canonical account-level region for those plan types; only EC2Instance plans are genuinely region-scoped, and validateSavingsPlanArgs already requires a non-blank region for those. details.Region was already correctly gated on EC2Instance; this closes the same gap for rec.Region. Every resolveClient closure forwarded the raw credential argument to ProviderConfig while CredentialScope trimmed the same value for the token, so a padded " prod " named one account in the token and another (or none) in the config that actually authenticates the purchase. Route all seven sites through CredentialScope itself rather than an open-coded TrimSpace: it is the function the token already uses, so the two can no longer diverge. Passing no env-var fallback yields the trimmed explicit value, and an empty result correctly means "use ambient credentials", which is what the token recorded anyway. Both guards were confirmed to fail against the pre-fix code. The credential test is table-driven across all seven resolveClient implementations rather than one per provider: the bug was seven independent copies of the same expression, so a guard on a subset would let a future edit reintroduce it elsewhere. TestEC2InstanceSavingsPlanStillHonorsRegion pins the other side, so the canonicalization cannot silently buy an EC2Instance plan in the wrong region. --- mcp/tools/aws_ec2_ri.go | 2 +- mcp/tools/aws_elasticache_ri.go | 2 +- mcp/tools/aws_rds_ri.go | 2 +- mcp/tools/aws_savingsplans.go | 19 ++- mcp/tools/aws_simple_ri.go | 2 +- mcp/tools/azure_compute_ri.go | 2 +- mcp/tools/gcp_computeengine_cud.go | 2 +- mcp/tools/idempotency_scope_test.go | 187 ++++++++++++++++++++++++++++ 8 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 mcp/tools/idempotency_scope_test.go diff --git a/mcp/tools/aws_ec2_ri.go b/mcp/tools/aws_ec2_ri.go index e87bf3f44..6bea3c947 100644 --- a/mcp/tools/aws_ec2_ri.go +++ b/mcp/tools/aws_ec2_ri.go @@ -200,7 +200,7 @@ func ec2RecommendationFromArgs(args ec2RIPurchaseArgs) (rec common.Recommendatio // resolves the provider/service client against a raw, un-trimmed value. func (t *awsEC2RIPurchaseTool) resolveClient(args ec2RIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: CredentialScope(args.AWSProfile), Region: region} prov, err := t.createProvider(string(common.ProviderAWS), cfg) if err != nil { return nil, err diff --git a/mcp/tools/aws_elasticache_ri.go b/mcp/tools/aws_elasticache_ri.go index 2271a3d49..a8b447bb9 100644 --- a/mcp/tools/aws_elasticache_ri.go +++ b/mcp/tools/aws_elasticache_ri.go @@ -151,7 +151,7 @@ func elasticacheRecommendationFromArgs(args elasticacheRIPurchaseArgs) (rec comm // client against a raw, un-trimmed value. func (t *awsElastiCacheRIPurchaseTool) resolveClient(args elasticacheRIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: CredentialScope(args.AWSProfile), Region: region} prov, err := t.createProvider(string(common.ProviderAWS), cfg) if err != nil { return nil, err diff --git a/mcp/tools/aws_rds_ri.go b/mcp/tools/aws_rds_ri.go index a29936595..e44ca47a3 100644 --- a/mcp/tools/aws_rds_ri.go +++ b/mcp/tools/aws_rds_ri.go @@ -159,7 +159,7 @@ func rdsRecommendationFromArgs(args rdsRIPurchaseArgs) (rec common.Recommendatio // raw, un-trimmed value. func (t *awsRDSRIPurchaseTool) resolveClient(args rdsRIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: CredentialScope(args.AWSProfile), Region: region} prov, err := t.createProvider(string(common.ProviderAWS), cfg) if err != nil { return nil, err diff --git a/mcp/tools/aws_savingsplans.go b/mcp/tools/aws_savingsplans.go index 1ced5c681..0504cb571 100644 --- a/mcp/tools/aws_savingsplans.go +++ b/mcp/tools/aws_savingsplans.go @@ -176,9 +176,20 @@ func savingsPlanRecommendationFromArgs(args savingsPlansPurchaseArgs) (rec commo trimmedRegion := strings.TrimSpace(args.Region) trimmedInstanceFamily := strings.TrimSpace(args.InstanceFamily) - region = trimmedRegion - if region == "" { - region = savingsPlansAccountLevelRegion + // Account-level plan types (Compute/SageMaker/Database) ignore region + // entirely, so pin the canonical one rather than honoring whatever the + // caller passed. region flows into rec.Region and from there into + // idempotencyKeyFor (mcp/tools/purchase.go), so letting an IGNORED input + // through would fork purchase identity: buying a $10/hr Compute SP once + // without region and then re-issuing the identical call with + // region="eu-west-1" (a self-correction, or a retry that fills the field + // in) derives two different tokens, Savings Plans' ClientToken dedupe + // misses, and a second plan is bought. Only EC2Instance plans are + // genuinely region-scoped, and validateSavingsPlanArgs already requires a + // non-blank region for those. + region = savingsPlansAccountLevelRegion + if spType == SPTypeEC2Instance { + region = trimmedRegion } // Resolve the precise per-plan-type ServiceType (e.g. @@ -298,7 +309,7 @@ func validateDatabaseSPConstraints(spType SPType, term TermYears, paymentOption func (t *awsSavingsPlansPurchaseTool) resolveClient(args savingsPlansPurchaseArgs, region string, service common.ServiceType) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: CredentialScope(args.AWSProfile), Region: region} prov, err := t.createProvider(string(common.ProviderAWS), cfg) if err != nil { return nil, err diff --git a/mcp/tools/aws_simple_ri.go b/mcp/tools/aws_simple_ri.go index e12ba82bb..d6b055cdd 100644 --- a/mcp/tools/aws_simple_ri.go +++ b/mcp/tools/aws_simple_ri.go @@ -200,7 +200,7 @@ func (t *simpleAWSRIPurchaseTool) recommendationFromArgs(args simpleAWSRIPurchas // raw, un-trimmed value. func (t *simpleAWSRIPurchaseTool) resolveClient(args simpleAWSRIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: args.AWSProfile, Region: region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAWS), AWSProfile: CredentialScope(args.AWSProfile), Region: region} prov, err := t.createProvider(string(common.ProviderAWS), cfg) if err != nil { return nil, err diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index 3cf87a892..a72a0c224 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -180,7 +180,7 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co // client against a raw, un-trimmed value. func (t *azureComputeRIPurchaseTool) resolveClient(args azureComputeRIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAzure), AzureSubscriptionID: args.AzureSubscriptionID, Region: region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAzure), AzureSubscriptionID: CredentialScope(args.AzureSubscriptionID), Region: region} prov, err := t.createProvider(string(common.ProviderAzure), cfg) if err != nil { return nil, err diff --git a/mcp/tools/gcp_computeengine_cud.go b/mcp/tools/gcp_computeengine_cud.go index 7aa9fe47c..50eefef8c 100644 --- a/mcp/tools/gcp_computeengine_cud.go +++ b/mcp/tools/gcp_computeengine_cud.go @@ -190,7 +190,7 @@ func gcpComputeEngineRecommendationFromArgs(args gcpComputeEngineCUDPurchaseArgs // client against a raw, un-trimmed value. func (t *gcpComputeEngineCUDPurchaseTool) resolveClient(args gcpComputeEngineCUDPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderGCP), GCPProjectID: args.GCPProjectID, Region: region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderGCP), GCPProjectID: CredentialScope(args.GCPProjectID), Region: region} prov, err := t.createProvider(string(common.ProviderGCP), cfg) if err != nil { return nil, err diff --git a/mcp/tools/idempotency_scope_test.go b/mcp/tools/idempotency_scope_test.go new file mode 100644 index 000000000..46f993696 --- /dev/null +++ b/mcp/tools/idempotency_scope_test.go @@ -0,0 +1,187 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/provider" +) + +// TestAccountLevelSavingsPlanRegionCannotForkIdempotencyToken is the +// regression guard for the double-purchase path CodeRabbit found: region is +// documented as "ignored for account-level plan types", but it still flowed +// into rec.Region and from there into idempotencyKeyFor. A model that bought +// a $10/hr Compute SP once without region and then re-issued the identical +// call with region="eu-west-1" (a self-correction, or a retry that fills the +// field in) derived two different tokens, so Savings Plans' ClientToken +// dedupe missed and a SECOND plan was purchased. +// +// An input the tool declares it ignores must not be able to change purchase +// identity, so both calls must produce byte-identical tokens. +func TestAccountLevelSavingsPlanRegionCannotForkIdempotencyToken(t *testing.T) { + t.Parallel() + + for _, spType := range []string{string(SPTypeCompute), string(SPTypeSageMaker)} { + t.Run(spType, func(t *testing.T) { + t.Parallel() + + withoutRegion := validSavingsPlansArgs() + withoutRegion.SPType = spType + withRegion := validSavingsPlansArgs() + withRegion.SPType = spType + withRegion.Region = "eu-west-1" + + recA, regionA, _, _, err := savingsPlanRecommendationFromArgs(withoutRegion) + require.NoError(t, err) + recB, regionB, _, _, err := savingsPlanRecommendationFromArgs(withRegion) + require.NoError(t, err) + + assert.Equal(t, savingsPlansAccountLevelRegion, regionA) + assert.Equal(t, savingsPlansAccountLevelRegion, regionB, + "an ignored region must not change the resolved region for an account-level plan") + + scope := CredentialScope(withRegion.AWSProfile, "AWS_PROFILE") + tokenA := idempotencyKeyFor(regionA, recA, scope, "") + tokenB := idempotencyKeyFor(regionB, recB, scope, "") + assert.Equal(t, tokenA, tokenB, + "supplying an ignored region must not fork the idempotency token -- a forked token means the retry does not dedupe and buys a second plan") + }) + } +} + +// TestEC2InstanceSavingsPlanStillHonorsRegion pins the other side of the +// canonicalization: EC2Instance plans ARE region-scoped, so region must still +// reach both the resolved region and the token. Without this, pinning the +// account-level region for every plan type would silently buy an EC2Instance +// plan in the wrong region. +func TestEC2InstanceSavingsPlanStillHonorsRegion(t *testing.T) { + t.Parallel() + + args := validSavingsPlansArgs() + args.SPType = string(SPTypeEC2Instance) + args.InstanceFamily = "m5" + args.Region = "eu-west-1" + + rec, region, _, _, err := savingsPlanRecommendationFromArgs(args) + require.NoError(t, err) + assert.Equal(t, "eu-west-1", region) + assert.Equal(t, "eu-west-1", rec.Region) + + details, ok := rec.Details.(*common.SavingsPlanDetails) + require.True(t, ok) + assert.Equal(t, "eu-west-1", details.Region) +} + +// TestResolveClientTrimsCredentialScope is the regression guard for the +// second CodeRabbit finding: every resolveClient closure forwarded the RAW +// credential argument to ProviderConfig while CredentialScope trimmed the +// same value for the idempotency token. A padded " test-profile " therefore +// named one account in the token and a different (or, for a profile that +// does not exist, no) account in the provider config that actually +// authenticates the purchase. +// +// Routing ProviderConfig through CredentialScope -- the very function the +// token uses -- is what makes the two incapable of diverging, so this asserts +// the config sees the normalized value. +// The table covers every resolveClient in this package rather than one +// representative per provider: the bug was seven independent copies of the +// same raw-forwarding expression, so a guard on a subset would let a future +// edit reintroduce it in the files it skipped. +func TestResolveClientTrimsCredentialScope(t *testing.T) { + t.Parallel() + + // got extracts the credential field the given tool is responsible for, so + // each case asserts on the one field its ProviderConfig actually sets. + cases := []struct { + name string + resolve func(createProvider func(string, *provider.ProviderConfig) (provider.Provider, error)) ResolveClientFunc + got func(cfg *provider.ProviderConfig) string + want string + }{ + { + name: "aws ec2", + resolve: func(cp func(string, *provider.ProviderConfig) (provider.Provider, error)) ResolveClientFunc { + return (&awsEC2RIPurchaseTool{createProvider: cp}). + resolveClient(ec2RIPurchaseArgs{AWSProfile: " padded-profile "}, "us-east-1") + }, + got: func(c *provider.ProviderConfig) string { return c.AWSProfile }, + want: "padded-profile", + }, + { + name: "aws elasticache", + resolve: func(cp func(string, *provider.ProviderConfig) (provider.Provider, error)) ResolveClientFunc { + return (&awsElastiCacheRIPurchaseTool{createProvider: cp}). + resolveClient(elasticacheRIPurchaseArgs{AWSProfile: " padded-profile "}, "us-east-1") + }, + got: func(c *provider.ProviderConfig) string { return c.AWSProfile }, + want: "padded-profile", + }, + { + name: "aws rds", + resolve: func(cp func(string, *provider.ProviderConfig) (provider.Provider, error)) ResolveClientFunc { + return (&awsRDSRIPurchaseTool{createProvider: cp}). + resolveClient(rdsRIPurchaseArgs{AWSProfile: " padded-profile "}, "us-east-1") + }, + got: func(c *provider.ProviderConfig) string { return c.AWSProfile }, + want: "padded-profile", + }, + { + name: "aws simple ri", + resolve: func(cp func(string, *provider.ProviderConfig) (provider.Provider, error)) ResolveClientFunc { + return (&simpleAWSRIPurchaseTool{createProvider: cp}). + resolveClient(simpleAWSRIPurchaseArgs{AWSProfile: " padded-profile "}, "us-east-1") + }, + got: func(c *provider.ProviderConfig) string { return c.AWSProfile }, + want: "padded-profile", + }, + { + name: "aws savings plans", + resolve: func(cp func(string, *provider.ProviderConfig) (provider.Provider, error)) ResolveClientFunc { + return (&awsSavingsPlansPurchaseTool{createProvider: cp}). + resolveClient(savingsPlansPurchaseArgs{AWSProfile: " padded-profile "}, "us-east-1", common.ServiceSavingsPlansCompute) + }, + got: func(c *provider.ProviderConfig) string { return c.AWSProfile }, + want: "padded-profile", + }, + { + name: "azure compute", + resolve: func(cp func(string, *provider.ProviderConfig) (provider.Provider, error)) ResolveClientFunc { + return (&azureComputeRIPurchaseTool{createProvider: cp}). + resolveClient(azureComputeRIPurchaseArgs{AzureSubscriptionID: " sub-x "}, "eastus") + }, + got: func(c *provider.ProviderConfig) string { return c.AzureSubscriptionID }, + want: "sub-x", + }, + { + name: "gcp compute engine", + resolve: func(cp func(string, *provider.ProviderConfig) (provider.Provider, error)) ResolveClientFunc { + return (&gcpComputeEngineCUDPurchaseTool{createProvider: cp}). + resolveClient(gcpComputeEngineCUDPurchaseArgs{GCPProjectID: " proj-a "}, "us-central1") + }, + got: func(c *provider.ProviderConfig) string { return c.GCPProjectID }, + want: "proj-a", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var gotCfg *provider.ProviderConfig + // The factory returns an error so the closure stops right after + // building the config: this asserts on what would have been used + // to authenticate, without needing a live provider. + resolve := tc.resolve(func(_ string, cfg *provider.ProviderConfig) (provider.Provider, error) { + gotCfg = cfg + return nil, assert.AnError + }) + _, _ = resolve(context.Background()) + require.NotNil(t, gotCfg, "createProvider must have been called with a config") + assert.Equal(t, tc.want, tc.got(gotCfg), + "ProviderConfig must see the same normalized credential the idempotency token scopes on") + }) + } +} From bdb43cd3060e3700f7c20fa8664e204472769c7a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 28 Jul 2026 01:15:56 +0200 Subject: [PATCH 81/83] test(mcp): assert EC2Instance regions derive different idempotency tokens Addresses the CodeRabbit finding on 44b609492. The test's own doc comment claimed region must still reach "the resolved region and the token" for EC2Instance plans, but it only asserted rec.Region and details.Region, leaving the token half of that claim untested. That gap matters in the opposite direction from the bug the previous commit fixed: a future change that canonicalized region for EC2Instance too would keep the field assertions green while collapsing two genuinely different purchases (m5 in eu-west-1 vs m5 in us-east-1) onto one token, at which point the second, legitimately distinct purchase would dedupe away and never happen. Derive tokens for the same plan in two regions and assert they differ. Confirmed the assertion fails when the region is pinned for every plan type, so it catches the over-pinning regression rather than restating what the field assertions already cover. --- mcp/tools/idempotency_scope_test.go | 31 ++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/mcp/tools/idempotency_scope_test.go b/mcp/tools/idempotency_scope_test.go index 46f993696..6a4c72f40 100644 --- a/mcp/tools/idempotency_scope_test.go +++ b/mcp/tools/idempotency_scope_test.go @@ -58,15 +58,26 @@ func TestAccountLevelSavingsPlanRegionCannotForkIdempotencyToken(t *testing.T) { // reach both the resolved region and the token. Without this, pinning the // account-level region for every plan type would silently buy an EC2Instance // plan in the wrong region. +// +// The token assertion is the load-bearing one. Checking only rec.Region and +// details.Region would leave the claim "the token is region-scoped" untested, +// so a future change that canonicalized region for EC2Instance too would keep +// those field assertions green while collapsing two genuinely different +// purchases (m5 in eu-west-1 vs m5 in us-east-1) onto one token -- at which +// point the second, legitimately distinct purchase would dedupe away and +// never happen. func TestEC2InstanceSavingsPlanStillHonorsRegion(t *testing.T) { t.Parallel() - args := validSavingsPlansArgs() - args.SPType = string(SPTypeEC2Instance) - args.InstanceFamily = "m5" - args.Region = "eu-west-1" + ec2InstanceArgs := func(region string) savingsPlansPurchaseArgs { + args := validSavingsPlansArgs() + args.SPType = string(SPTypeEC2Instance) + args.InstanceFamily = "m5" + args.Region = region + return args + } - rec, region, _, _, err := savingsPlanRecommendationFromArgs(args) + rec, region, _, _, err := savingsPlanRecommendationFromArgs(ec2InstanceArgs("eu-west-1")) require.NoError(t, err) assert.Equal(t, "eu-west-1", region) assert.Equal(t, "eu-west-1", rec.Region) @@ -74,6 +85,16 @@ func TestEC2InstanceSavingsPlanStillHonorsRegion(t *testing.T) { details, ok := rec.Details.(*common.SavingsPlanDetails) require.True(t, ok) assert.Equal(t, "eu-west-1", details.Region) + + otherRec, otherRegion, _, _, err := savingsPlanRecommendationFromArgs(ec2InstanceArgs("us-east-1")) + require.NoError(t, err) + assert.Equal(t, "us-east-1", otherRegion) + + scope := CredentialScope(validSavingsPlansArgs().AWSProfile, "AWS_PROFILE") + assert.NotEqual(t, + idempotencyKeyFor(region, rec, scope, ""), + idempotencyKeyFor(otherRegion, otherRec, scope, ""), + "an EC2Instance plan is region-scoped, so two regions must derive DIFFERENT tokens -- collapsing them would dedupe away a legitimately distinct purchase") } // TestResolveClientTrimsCredentialScope is the regression guard for the From a1d26968643e1067bac904de6f9673406c3790e7 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 28 Jul 2026 17:01:45 +0200 Subject: [PATCH 82/83] fix(mcp,azure): trim search credential overrides and stop misblaming partial-upfront Addresses the two unresolved CodeRabbit threads on bdb43cd30. trimSearchArgsIdentifiers normalized service, region, include_regions, exclude_regions, and account_filter but not the three credential overrides, so providerConfigFromArgs forwarded aws_profile / azure_subscription_id / gcp_project_id into ProviderConfig raw. A padded " my-profile " then failed credential resolution for a reason the resulting error never mentions, reading as "these credentials are broken" rather than "this name has a stray space". The purchase tools already normalize the same three fields through CredentialScope; this closes the gap on the search path. BillingPlanForPaymentOption's default branch appended "(partial-upfront has no azure equivalent)" to every unrecognized value, so a typo ("montly") or a value from another provider's vocabulary ("prepaid") was diagnosed as a partial-upfront problem the caller never had. Give partial-upfront its own case -- it is a real payment option elsewhere in CUDly, so the caller does need to know Azure has no equivalent -- and report anything else as simply not recognized. This extends the same split an earlier commit already made for the empty value, for the same reason: an error that names the wrong cause sends whoever reads it looking in the wrong place. A dedicated case rather than a conditional inside default, so the switch's already-lowercased and trimmed value decides it instead of re-normalizing. Both guards were confirmed to fail against the pre-fix code. The Azure test also pins that " Partial-Upfront " still reaches the partial-upfront case rather than falling through to the unrecognized branch. --- mcp/tools/search_recommendations.go | 10 +++ mcp/tools/search_recommendations_test.go | 67 +++++++++++++++++++ .../internal/reservations/purchase.go | 15 ++++- .../internal/reservations/purchase_test.go | 34 ++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index 44fa62510..239bef34c 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -415,6 +415,16 @@ func trimSearchArgsIdentifiers(args searchRecommendationsArgs) searchRecommendat args.IncludeRegions = trimAll(args.IncludeRegions) args.ExcludeRegions = trimAll(args.ExcludeRegions) args.AccountFilter = trimAll(args.AccountFilter) + // The credential overrides flow straight into ProviderConfig via + // providerConfigFromArgs, where they select the account/subscription/ + // project the search runs against. A padded " my-profile " fails profile + // resolution for a reason the error never mentions (the whitespace), + // which reads as "these credentials are broken" rather than "this name + // has a stray space". The purchase tools normalize the same three fields + // through CredentialScope for the equivalent reason. + args.AWSProfile = strings.TrimSpace(args.AWSProfile) + args.AzureSubscriptionID = strings.TrimSpace(args.AzureSubscriptionID) + args.GCPProjectID = strings.TrimSpace(args.GCPProjectID) return args } diff --git a/mcp/tools/search_recommendations_test.go b/mcp/tools/search_recommendations_test.go index 419eb62a8..95e7237f4 100644 --- a/mcp/tools/search_recommendations_test.go +++ b/mcp/tools/search_recommendations_test.go @@ -196,6 +196,73 @@ func TestSearchRecommendationsTrimsRegionFilters(t *testing.T) { assert.Equal(t, []string{"123456789012"}, client.lastParams.AccountFilter, "AccountFilter entries must be trimmed") } +// TestSearchRecommendationsTrimsCredentialOverrides covers the three +// credential override fields, which trimSearchArgsIdentifiers normalized +// every other identifier except. They select the account/subscription/ +// project the search runs against, so a padded " my-profile " reached +// ProviderConfig raw and failed credential resolution for a reason the +// resulting error never mentions -- reading as "these credentials are +// broken" rather than "this name has a stray space". Asserted on +// ProviderConfig itself, since that is what actually authenticates. +func TestSearchRecommendationsTrimsCredentialOverrides(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + provider string + service common.ServiceType + args searchRecommendationsArgs + got func(cfg *provider.ProviderConfig) string + want string + }{ + { + name: "aws profile", + provider: "aws", + service: common.ServiceEC2, + args: searchRecommendationsArgs{Provider: "aws", Service: "ec2", AWSProfile: " my-profile "}, + got: func(c *provider.ProviderConfig) string { return c.AWSProfile }, + want: "my-profile", + }, + { + name: "azure subscription id", + provider: "azure", + service: common.ServiceCompute, + args: searchRecommendationsArgs{Provider: "azure", Service: "compute", AzureSubscriptionID: " sub-x "}, + got: func(c *provider.ProviderConfig) string { return c.AzureSubscriptionID }, + want: "sub-x", + }, + { + name: "gcp project id", + provider: "gcp", + service: common.ServiceCompute, + args: searchRecommendationsArgs{Provider: "gcp", Service: "compute", GCPProjectID: " proj-a "}, + got: func(c *provider.ProviderConfig) string { return c.GCPProjectID }, + want: "proj-a", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + client := &fakeRecommendationsClient{} + fp := &fakeProvider{name: tc.provider, services: []common.ServiceType{tc.service}, recClient: client} + var gotCfg *provider.ProviderConfig + tool := &searchRecommendationsTool{ + createProvider: func(_ string, cfg *provider.ProviderConfig) (provider.Provider, error) { + gotCfg = cfg + return fp, nil + }, + } + + _, _, err := tool.handle(context.Background(), nil, tc.args) + require.NoError(t, err) + require.NotNil(t, gotCfg) + assert.Equal(t, tc.want, tc.got(gotCfg), + "credential overrides must reach ProviderConfig trimmed, like every other identifier") + }) + } +} + func TestSearchRecommendationsInvalidProvider(t *testing.T) { t.Parallel() tool := newTestSearchTool(&fakeProvider{}) diff --git a/providers/azure/services/internal/reservations/purchase.go b/providers/azure/services/internal/reservations/purchase.go index 57c22fd6b..df2f31d43 100644 --- a/providers/azure/services/internal/reservations/purchase.go +++ b/providers/azure/services/internal/reservations/purchase.go @@ -113,10 +113,23 @@ func BillingPlanForPaymentOption(paymentOption string) (armreservations.Reservat "azure reservations support only upfront or monthly billing, and no payment option was supplied; " + "set it explicitly (upfront or monthly) rather than relying on a default, because Azure's own " + "default is upfront and would charge the whole commitment immediately") - default: + case "partial-upfront": + // Named explicitly rather than folded into default: partial-upfront is + // a real payment option elsewhere in CUDly (AWS offers it), so the + // caller needs to know Azure specifically has no equivalent. Handled + // as its own case so the switch's already-normalized value decides it, + // rather than re-lowercasing inside default. return "", fmt.Errorf( "azure reservations support only upfront or monthly billing; %q is not available (partial-upfront has no azure equivalent)", paymentOption) + default: + // Anything else is a typo or an unknown value. Blaming partial-upfront + // here would misdirect debugging: %q is not partial-upfront, so saying + // "partial-upfront has no azure equivalent" describes a value the + // caller never supplied. + return "", fmt.Errorf( + "azure reservations support only upfront or monthly billing; %q is not a recognized payment option", + paymentOption) } } diff --git a/providers/azure/services/internal/reservations/purchase_test.go b/providers/azure/services/internal/reservations/purchase_test.go index 42f50a8ca..f731b5af6 100644 --- a/providers/azure/services/internal/reservations/purchase_test.go +++ b/providers/azure/services/internal/reservations/purchase_test.go @@ -776,3 +776,37 @@ func TestBillingPlanForPaymentOption_EmptyHasItsOwnMessage(t *testing.T) { require.Error(t, blankErr) assert.Contains(t, blankErr.Error(), "no payment option was supplied") } + +// TestBillingPlanForPaymentOption_UnrecognizedIsNotBlamedOnPartialUpfront +// extends the same split one step further: before this, EVERY unrecognized +// value was reported as "partial-upfront has no azure equivalent", so a typo +// ("montly") or a value from another provider's vocabulary ("prepaid") was +// diagnosed as a partial-upfront problem the caller never had. Only an +// actual partial-upfront should mention partial-upfront; anything else is +// simply not a recognized option. +func TestBillingPlanForPaymentOption_UnrecognizedIsNotBlamedOnPartialUpfront(t *testing.T) { + t.Parallel() + + for _, unrecognized := range []string{"bogus", "prepaid", "montly", "PARTIAL"} { + _, err := BillingPlanForPaymentOption(unrecognized) + require.Errorf(t, err, "payment_option=%q must error", unrecognized) + assert.Containsf(t, err.Error(), "is not a recognized payment option", + "payment_option=%q should be diagnosed as unrecognized", unrecognized) + assert.NotContainsf(t, err.Error(), "partial-upfront has no azure equivalent", + "payment_option=%q is not partial-upfront, so the error must not blame it", unrecognized) + assert.Containsf(t, err.Error(), unrecognized, + "the error must quote the value actually supplied") + } + + // partial-upfront itself keeps its specific message: it is a real payment + // option on other providers, so the caller needs to know Azure has no + // equivalent rather than that they typo'd. + _, partialErr := BillingPlanForPaymentOption("partial-upfront") + require.Error(t, partialErr) + assert.Contains(t, partialErr.Error(), "partial-upfront has no azure equivalent") + // Case and whitespace are normalized by the switch, so these reach the + // same case rather than falling through to the unrecognized branch. + _, paddedErr := BillingPlanForPaymentOption(" Partial-Upfront ") + require.Error(t, paddedErr) + assert.Contains(t, paddedErr.Error(), "partial-upfront has no azure equivalent") +} From 707cd14a01c38330b961cac92e2bfd831a85cdb8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 28 Jul 2026 18:01:43 +0200 Subject: [PATCH 83/83] fix(mcp): stop Azure subscription-ID case forking purchase identity ARM subscription IDs are case-insensitive GUIDs, and nothing in providers/azure canonicalizes them -- resolveAzureSubscriptionID forwards whatever it is given straight into the request path. CredentialScope trimmed the value but never folded its case, so the SAME subscription spelled two ways derived two DIFFERENT idempotency tokens. The double-spend that follows: 1. A purchase is issued with azure_subscription_id="ABC12345-1234-1234-1234-1234567890AB", the spelling the Azure portal hands you, and times out. 2. The model retries. This time the override is omitted, so the value comes from AZURE_SUBSCRIPTION_ID, which conventionally holds the lower-case "abc12345-...". 3. Both calls target the same subscription, but the tokens differ, so reservations.FindReservationOrderByIdempotencyToken -- which matches on the token tag across the TENANT-wide order list -- misses the first order. 4. A SECOND reservation is purchased. This is the defect family 44b6094 fixed for untrimmed input: purchase identity forked by a difference the provider itself does not recognize. Whitespace was normalized there; case was not. Both Azure uses of the subscription ID -- the idempotency scope in handle and the ProviderConfig in resolveClient -- now go through the single azureCredentialScope helper, so they cannot normalize it differently. Case folding is deliberately NOT pushed into CredentialScope: AWS named profiles are case-SENSITIVE section names in ~/.aws/config, so lower-casing one would point a real purchase at a profile that does not exist or at a different one that does. GCP project IDs are lower-case by grammar. The new guard covers both directions -- two cases of one Azure subscription must derive ONE token, two different subscriptions must keep two -- and asserts AWS profile case is still preserved end to end, so the Azure fix cannot leak into the AWS path. It fails on the pre-fix code. Also in this change: - cudly_search_recommendations no longer claims it "spends no money". Since d76aa7c, a reservation search that omits term_years and payment_option fans out to six Cost Explorer requests, and AWS bills those per request. The tool buys no commitment, but it is not free, and a model told otherwise may loop on it. The description now says both. - The account-level Savings Plan region guard covers Database alongside Compute and SageMaker. It needs its own 1yr/no-upfront fixture because validateDatabaseSavingsPlan permits nothing else. Database is account-level by construction today (the branch tests for EC2Instance), so the case exists to stop a refactor that enumerates the account-level types explicitly from quietly dropping it. Deferred to #1586: parseRecommendations logs and continues when one recommendation detail fails to parse, so a combo whose best offer fails quantity/cost parsing vanishes silently -- recreating within a combo the "5 of 6 offers presented as all your options" defect the fan-out exists to prevent. The parser is shared with the scheduler's discovery sweep, which legitimately wants the tolerant behaviour, so the fix needs a per-caller policy and does not belong on this branch. --- mcp/README.md | 2 +- mcp/tools/azure_compute_ri.go | 43 ++++++- mcp/tools/idempotency_scope_test.go | 170 ++++++++++++++++++++++++++-- mcp/tools/purchase.go | 7 ++ mcp/tools/search_recommendations.go | 16 ++- 5 files changed, 225 insertions(+), 13 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index e0bb781c7..ea6fc3178 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -122,7 +122,7 @@ These are pre-existing behaviours in the underlying purchase clients, not someth - **An Azure purchase with no `payment_option` now fails loud instead of silently billing all-upfront.** `reservations.BillingPlanForPaymentOption` used to fall through an empty payment option to Azure's own implicit default, which is Upfront -- charging the entire commitment immediately even though nobody asked for that schedule. It now returns an explicit error naming the missing value. This is reachable outside the MCP server: migration `000032` added `recommendations.payment_option` as `TEXT NOT NULL` defaulting to the empty string, and `internal/purchase/execution.go` passes `rec.Payment` through without a non-empty check, so **scheduled purchases created from rows predating that migration will now fail rather than silently charge upfront.** That is the intended trade (an unrequested full-upfront charge is worse than a refusal), but it is a live behaviour change: if a scheduled Azure execution starts failing with "no payment option was supplied", set the row's `payment_option` explicitly and re-run. The MCP tool itself is unaffected -- `cudly_azure_compute_ri_purchase` defaults `payment_option` to no-upfront before the client is reached. - **GCP Compute Engine CUDs commit resources, not instances.** `cudly_gcp_computeengine_cud_purchase` takes `vcpu_count` and `memory_gb` directly (a CUD is a vCPU+memory commitment), not an instance count -- there is no implicit vCPU-per-instance conversion. - **AWS Savings Plans searches require term/payment/lookback; `cudly_search_recommendations` defaults them.** Unlike EC2/RDS reservation searches, `GetSavingsPlansPurchaseRecommendation` rejects the call unless all three are set. When `service` targets a Savings Plans search on AWS, the tool fills in unset fields with `term_years=1` (1yr), `payment_option=no-upfront`, `lookback_period=30d` -- an explicitly supplied value is never overridden. A Savings Plans search therefore resolves to exactly one (term, payment, lookback) triple and issues exactly one Cost Explorer call. -- **Reservation searches fan out over every term and payment option when you omit them.** `GetReservationPurchaseRecommendation` accepts exactly one `TermInYears` and one `PaymentOption` per request and returns recommendations only for that cell -- there is no "give me every variant" mode. So when `term_years` and/or `payment_option` are omitted on an EC2/RDS/ElastiCache/etc search, the tool expands the omitted dimension to its full menu and issues one call per combination (6 when both are omitted, 2 or 3 when one is), returning the concatenated results. Each returned recommendation carries the `term`/`payment_option` of the request that produced it, so its money figures are attributable to a specific offer. If any one combination fails, the whole search fails rather than returning a partial menu -- five of six offers is indistinguishable from "these are all your options". Note that `lookback_period` is NOT fanned out: omitting it leaves Cost Explorer's own server-side default (7 days) in place, since the lookback window is the usage evidence behind an offer rather than another offer to choose from. +- **Reservation searches fan out over every term and payment option when you omit them.** `GetReservationPurchaseRecommendation` accepts exactly one `TermInYears` and one `PaymentOption` per request and returns recommendations only for that cell -- there is no "give me every variant" mode. So when `term_years` and/or `payment_option` are omitted on an EC2/RDS/ElastiCache/etc search, the tool expands the omitted dimension to its full menu and issues one call per combination (6 when both are omitted, 2 or 3 when one is), returning the concatenated results. Each returned recommendation carries the `term`/`payment_option` of the request that produced it, so its money figures are attributable to a specific offer. If any one combination fails, the whole search fails rather than returning a partial menu -- five of six offers is indistinguishable from "these are all your options". Note that `lookback_period` is NOT fanned out: omitting it leaves Cost Explorer's own server-side default (7 days) in place, since the lookback window is the usage evidence behind an offer rather than another offer to choose from. The fan-out multiplies the API bill along with the coverage: `cudly_search_recommendations` buys no commitment, but Cost Explorer bills per request, so an omitted-term/omitted-payment search costs six requests rather than one (more with pagination). The tool description states this too, so a model does not treat searching as free and re-run it in a loop. - **`region` on `cudly_search_recommendations` is a client-side post-filter for AWS reservation/Savings Plans searches.** `GetReservationPurchaseRecommendation` and `GetSavingsPlansPurchaseRecommendation` are account-level Cost Explorer calls with no region parameter -- AWS returns recommendations from every region the account has usage in. The tool filters the returned recommendations down to `region`/`include_regions` (minus `exclude_regions`) after the call; when no region constraint is supplied, all regions are returned as before. ## Deployment model diff --git a/mcp/tools/azure_compute_ri.go b/mcp/tools/azure_compute_ri.go index a72a0c224..03db1b0ca 100644 --- a/mcp/tools/azure_compute_ri.go +++ b/mcp/tools/azure_compute_ri.go @@ -3,6 +3,7 @@ package tools import ( "context" "fmt" + "strings" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -93,6 +94,36 @@ func (t *azureComputeRIPurchaseTool) Register(s *mcp.Server) error { return nil } +// azureCredentialScope resolves the Azure subscription a purchase is billed +// to, lower-cased. Every use of the subscription ID in this file goes through +// it -- the idempotency scope in handle AND the ProviderConfig in resolveClient +// -- so the two cannot normalize it differently. That is the same reason both +// already route through CredentialScope rather than one of them reading +// args.AzureSubscriptionID raw. +// +// Case folding is the load-bearing part. ARM subscription IDs are +// case-insensitive GUIDs and nothing in providers/azure canonicalizes them +// (resolveAzureSubscriptionID forwards the configured value verbatim into the +// request path), so "ABC12345-..." pasted out of the portal and the lower-case +// "abc12345-..." that AZURE_SUBSCRIPTION_ID normally holds address the SAME +// subscription while deriving DIFFERENT idempotency tokens. A purchase issued +// under the first spelling that times out, then retried under the second (the +// override omitted, so the value comes from the environment), therefore misses +// reservations.FindReservationOrderByIdempotencyToken -- which matches on the +// token tag across the TENANT-wide order list -- and buys a SECOND reservation. +// That is the defect family 44b6094 fixed for untrimmed input: purchase +// identity forked by a difference the provider itself does not recognize. +// Whitespace was normalized there; case was not. +// +// Deliberately Azure-only rather than folded into CredentialScope: AWS named +// profiles are case-SENSITIVE (they are section names in ~/.aws/config), so +// lower-casing one would point a real purchase at a profile that does not +// exist, or at a different one that does. GCP project IDs are lower-case by +// grammar and need no folding. +func azureCredentialScope(explicit string, envVars ...string) string { + return strings.ToLower(CredentialScope(explicit, envVars...)) +} + func (t *azureComputeRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolRequest, args azureComputeRIPurchaseArgs) (*mcp.CallToolResult, PurchaseResponse, error) { rec, region, dryRun, confirm, err := azureComputeRecommendationFromArgs(args) if err != nil { @@ -106,7 +137,7 @@ func (t *azureComputeRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallTool Confirm: confirm, ResolveClient: t.resolveClient(args, region), Nonce: args.IdempotencyNonce, - CredentialScope: CredentialScope(args.AzureSubscriptionID, "AZURE_SUBSCRIPTION_ID"), + CredentialScope: azureCredentialScope(args.AzureSubscriptionID, "AZURE_SUBSCRIPTION_ID"), }) if err != nil { return nil, PurchaseResponse{}, err @@ -178,9 +209,17 @@ func azureComputeRecommendationFromArgs(args azureComputeRIPurchaseArgs) (rec co // trimmed region returned by azureComputeRecommendationFromArgs -- not // args.Region -- so a real purchase never resolves the provider/service // client against a raw, un-trimmed value. +// +// The subscription goes through azureCredentialScope, the same function the +// idempotency scope uses, so the account the token names and the account the +// purchase authenticates against are normalized identically by construction. +// No environment variable is passed here (unlike the handler's scope) because +// an empty AzureSubscriptionID is how this config asks the provider factory to +// resolve the subscription itself; ARM treats the ID case-insensitively, so +// lower-casing it does not change which subscription is addressed. func (t *azureComputeRIPurchaseTool) resolveClient(args azureComputeRIPurchaseArgs, region string) ResolveClientFunc { return func(ctx context.Context) (provider.ServiceClient, error) { - cfg := &provider.ProviderConfig{Name: string(common.ProviderAzure), AzureSubscriptionID: CredentialScope(args.AzureSubscriptionID), Region: region} + cfg := &provider.ProviderConfig{Name: string(common.ProviderAzure), AzureSubscriptionID: azureCredentialScope(args.AzureSubscriptionID), Region: region} prov, err := t.createProvider(string(common.ProviderAzure), cfg) if err != nil { return nil, err diff --git a/mcp/tools/idempotency_scope_test.go b/mcp/tools/idempotency_scope_test.go index 6a4c72f40..c4c5e36b2 100644 --- a/mcp/tools/idempotency_scope_test.go +++ b/mcp/tools/idempotency_scope_test.go @@ -22,18 +22,43 @@ import ( // // An input the tool declares it ignores must not be able to change purchase // identity, so both calls must produce byte-identical tokens. +// +// Every account-level plan type is covered, not a representative one. Database +// needs its own term/payment because validateDatabaseSavingsPlan +// (aws_savingsplans.go) permits only 1yr/no-upfront, which is why the shared +// 3yr fixture cannot carry it. Today the canonicalization branches on +// `spType == SPTypeEC2Instance`, so Database is account-level by construction +// and cannot regress on its own; the case exists so that a refactor which +// instead enumerates the account-level types explicitly cannot quietly leave +// Database out of the list. func TestAccountLevelSavingsPlanRegionCannotForkIdempotencyToken(t *testing.T) { t.Parallel() - for _, spType := range []string{string(SPTypeCompute), string(SPTypeSageMaker)} { - t.Run(spType, func(t *testing.T) { + cases := []struct { + spType SPType + termYears int + paymentOption PaymentOption + }{ + {spType: SPTypeCompute, termYears: int(TermThreeYear), paymentOption: PaymentOptionNoUpfront}, + {spType: SPTypeSageMaker, termYears: int(TermThreeYear), paymentOption: PaymentOptionNoUpfront}, + {spType: SPTypeDatabase, termYears: int(TermOneYear), paymentOption: PaymentOptionNoUpfront}, + } + + for _, tc := range cases { + t.Run(string(tc.spType), func(t *testing.T) { t.Parallel() - withoutRegion := validSavingsPlansArgs() - withoutRegion.SPType = spType - withRegion := validSavingsPlansArgs() - withRegion.SPType = spType - withRegion.Region = "eu-west-1" + argsFor := func(region string) savingsPlansPurchaseArgs { + args := validSavingsPlansArgs() + args.SPType = string(tc.spType) + args.TermYears = tc.termYears + args.PaymentOption = string(tc.paymentOption) + args.Region = region + return args + } + + withoutRegion := argsFor("") + withRegion := argsFor("eu-west-1") recA, regionA, _, _, err := savingsPlanRecommendationFromArgs(withoutRegion) require.NoError(t, err) @@ -206,3 +231,134 @@ func TestResolveClientTrimsCredentialScope(t *testing.T) { }) } } + +// azurePurchaseIdentity drives a REAL Azure purchase through the tool's own +// handler and reports the two things that decide purchase identity: the +// idempotency token the provider would dedupe on, and the subscription the +// ProviderConfig would authenticate with. +// +// It goes through handle() rather than re-deriving the scope expression +// because that expression is exactly what is under test: a test that recomputed +// azureCredentialScope(...) itself would stay green if handle() stopped calling +// it. +func azurePurchaseIdentity(t *testing.T, subscriptionID string) (token, cfgSubscriptionID string) { + t.Helper() + + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + var gotCfg *provider.ProviderConfig + tool := &azureComputeRIPurchaseTool{ + createProvider: func(_ string, cfg *provider.ProviderConfig) (provider.Provider, error) { + gotCfg = cfg + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "azure"}, + client: fake, + gotService: new(common.ServiceType), + gotRegion: new(string), + }, nil + }, + } + + args := validAzureComputeArgs() + args.AzureSubscriptionID = subscriptionID + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + require.True(t, resp.Success) + require.NotNil(t, gotCfg, "createProvider must have been called with a config") + return fake.lastOpts.IdempotencyToken, gotCfg.AzureSubscriptionID +} + +// TestAzureSubscriptionCaseCannotForkIdempotencyToken is the regression guard +// for a double-spend on the Azure purchase path. +// +// ARM subscription IDs are case-insensitive GUIDs, and nothing in +// providers/azure canonicalizes them. So a purchase issued with +// azure_subscription_id="ABC12345-..." (the spelling the Azure portal hands +// you) that times out, then retried with the override omitted so the value +// comes from a lower-case AZURE_SUBSCRIPTION_ID, named the SAME subscription +// through two different idempotency tokens. Azure's dedupe +// (reservations.FindReservationOrderByIdempotencyToken) matches on the token +// tag across the tenant-wide order list, so the retry's lookup missed the +// first order and bought a SECOND reservation. +// +// This is the same shape as the untrimmed-credential fork fixed in 44b6094 -- +// purchase identity forked by a difference the provider does not recognize -- +// and it fails on the pre-fix code, which folded the verbatim string into the +// token. +func TestAzureSubscriptionCaseCannotForkIdempotencyToken(t *testing.T) { + t.Parallel() + + // Same subscription, two spellings: as pasted from the portal, and as + // AZURE_SUBSCRIPTION_ID conventionally holds it. + const ( + portalCase = "ABC12345-1234-1234-1234-1234567890AB" + envCase = "abc12345-1234-1234-1234-1234567890ab" + // A genuinely different subscription, as the control: the fix must + // canonicalize case without collapsing distinct accounts onto one + // token, which would dedupe away a legitimate second purchase. + otherSubscription = "99999999-9999-9999-9999-999999999999" + ) + + portalToken, portalCfg := azurePurchaseIdentity(t, portalCase) + envToken, envCfg := azurePurchaseIdentity(t, envCase) + otherToken, _ := azurePurchaseIdentity(t, otherSubscription) + + assert.Equal(t, portalToken, envToken, + "the same subscription in two cases must derive ONE token -- two tokens means the retry misses Azure's tenant-wide lookup and buys a second reservation") + assert.NotEqual(t, portalToken, otherToken, + "two genuinely different subscriptions must still derive different tokens") + + assert.Equal(t, envCase, portalCfg, + "ProviderConfig must see the same canonicalized subscription the token scopes on") + assert.Equal(t, envCase, envCfg) +} + +// TestAWSProfileCaseIsPreserved pins the boundary of the Azure fix: AWS named +// profiles are section names in ~/.aws/config and ARE case-sensitive, so +// "Prod" and "prod" are two different profiles that may authenticate as two +// different accounts. +// +// Without this, a later "just normalize credential scope everywhere" cleanup +// would look harmless and would both send a real purchase to a profile that +// may not exist and collapse two genuinely distinct accounts onto one +// idempotency token. +func TestAWSProfileCaseIsPreserved(t *testing.T) { + t.Parallel() + + purchaseAs := func(profile string) (token, cfgProfile string) { + fake := &fakeServiceClient{purchaseResult: common.PurchaseResult{Success: true}} + var gotCfg *provider.ProviderConfig + tool := &awsEC2RIPurchaseTool{ + createProvider: func(_ string, cfg *provider.ProviderConfig) (provider.Provider, error) { + gotCfg = cfg + return &recordingProvider{ + fakeProvider: &fakeProvider{name: "aws"}, + client: fake, + gotService: new(common.ServiceType), + gotRegion: new(string), + }, nil + }, + } + + args := validEC2Args() + args.AWSProfile = profile + args.DryRun = boolPtr(false) + args.Confirm = boolPtr(true) + + _, resp, err := tool.handle(context.Background(), nil, args) + require.NoError(t, err) + require.True(t, resp.Success) + require.NotNil(t, gotCfg, "createProvider must have been called with a config") + return fake.lastOpts.IdempotencyToken, gotCfg.AWSProfile + } + + upperToken, upperCfg := purchaseAs("Prod") + lowerToken, lowerCfg := purchaseAs("prod") + + assert.Equal(t, "Prod", upperCfg, "an AWS profile name must reach the provider config verbatim") + assert.Equal(t, "prod", lowerCfg) + assert.NotEqual(t, upperToken, lowerToken, + "AWS profile names are case-sensitive, so two cases are two accounts and must keep two tokens -- the Azure case-folding fix must not leak here") +} diff --git a/mcp/tools/purchase.go b/mcp/tools/purchase.go index 3241e73a2..4d59f447b 100644 --- a/mcp/tools/purchase.go +++ b/mcp/tools/purchase.go @@ -112,6 +112,13 @@ type PurchaseRequest struct { // (single visible Azure subscription, AWS STS identity, ambient GCP project) // is unambiguous for the life of the server process, so there is no second // scope for a token to collide with. +// +// Normalization here is limited to trimming, because it is the only rule that +// is safe for all three providers. Case is deliberately NOT folded: AWS named +// profiles are case-sensitive section names in ~/.aws/config, so lower-casing +// one could name a profile that does not exist or a different one that does. +// Azure subscription IDs ARE case-insensitive and do need folding -- see +// azureCredentialScope in azure_compute_ri.go, which wraps this. func CredentialScope(explicit string, envVars ...string) string { if s := strings.TrimSpace(explicit); s != "" { return s diff --git a/mcp/tools/search_recommendations.go b/mcp/tools/search_recommendations.go index 239bef34c..9ad30b31d 100644 --- a/mcp/tools/search_recommendations.go +++ b/mcp/tools/search_recommendations.go @@ -14,10 +14,20 @@ import ( const searchRecommendationsName = "cudly_search_recommendations" +// searchRecommendationsDescription ships in the tool schema, so the model +// reads it as the contract for what calling this costs. It states the +// guarantee that matters (nothing is ever bought) without claiming the call is +// free: an AWS reservation search that omits term_years and payment_option +// fans out to six Cost Explorer requests (searchCombos), and Cost Explorer +// bills per request. Saying "spends no money" would invite a model to re-run +// searches in a loop believing that has no cost. const searchRecommendationsDescription = "Search for reserved-capacity purchase recommendations (RI/SP/CUD) " + - "across AWS, Azure, or GCP. Read-only: makes no purchase and spends no money -- there is no dry_run or " + - "confirm parameter because nothing is ever bought. Use this first to find what to buy, then feed a result's " + - "region/resource_type/count into the matching cudly____purchase tool." + "across AWS, Azure, or GCP. Read-only: makes no purchase and buys no commitment -- there is no dry_run or " + + "confirm parameter because nothing is ever bought. It is not free of charge, though: an AWS search issues " + + "one or more Cost Explorer requests (up to 6 when term_years and payment_option are both omitted, more " + + "with pagination), and AWS bills those per request, so avoid re-running the same search in a loop. Use " + + "this first to find what to buy, then feed a result's region/resource_type/count into the matching " + + "cudly____purchase tool." // searchRecommendationsArgs mirrors common.RecommendationParams, adding the // provider selector and the optional per-call credential overrides from the