From bcc65db4e91eb0a13174d7ae0b5517b5bc97cee6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 00:25:00 +0200 Subject: [PATCH 01/43] 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 1eae01f7d5e56606979e3ebabade2e3c1a83c28b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 00:56:52 +0200 Subject: [PATCH 02/43] 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 188658c4a05bdce6fb556304321e69974caa640c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 00:58:56 +0200 Subject: [PATCH 03/43] 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 d503187b18ddbc297052012d5ae85918eaaf9879 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:20:32 +0200 Subject: [PATCH 04/43] 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 4e151560d..5dbda56fd 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.39.0 // indirect diff --git a/go.sum b/go.sum index c412d33fa..32659d76d 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 a0a75f21db94ad36cdca5ef60dfbc3e1b4b7ad89 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:24:48 +0200 Subject: [PATCH 05/43] 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 1d1909a7ec70bd2ba9cbf7ae41bd325741637074 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:29:06 +0200 Subject: [PATCH 06/43] 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 072a2927950f7f81f1ac2e026cfb05ad0e04d903 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:32:16 +0200 Subject: [PATCH 07/43] 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 0ad6e0c838e451b1a2b23a54ab85946617761216 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:35:23 +0200 Subject: [PATCH 08/43] 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 8f53e921f89e482665bac196a63903a9115aff09 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:39:10 +0200 Subject: [PATCH 09/43] 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 2780c70fa2594786d3b505c0f3e7f9e7e8e75c87 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:44:08 +0200 Subject: [PATCH 10/43] 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 0f8e790f43a5958f1947bd9b6c8451361cc5a830 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:45:21 +0200 Subject: [PATCH 11/43] 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 3438ceff3caf523644c4bb422cb40266d19e9f1b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:53:15 +0200 Subject: [PATCH 12/43] 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 6581b271487d982bc7a3749ad12517e6af0c5b9f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:56:17 +0200 Subject: [PATCH 13/43] 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 febedc672aaa3aef73b4e121ac804868f7ac004c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:17:36 +0200 Subject: [PATCH 14/43] 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 01fcb409adec87448ca959e0ae4123679d25abef Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:21:43 +0200 Subject: [PATCH 15/43] 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 f2d79e8cba7b506a672052e53ae2ed0501729264 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:24:05 +0200 Subject: [PATCH 16/43] 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 e4df0cf12cc7666ed0fb9c26ad43351ee592b384 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:30:50 +0200 Subject: [PATCH 17/43] 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 0731cab20e894317b3721a57a1c60e68a0359f0a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:35:37 +0200 Subject: [PATCH 18/43] 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 0a657e16aba6750094eb05df80d990dff04dbd73 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:35:53 +0200 Subject: [PATCH 19/43] 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 1ad2e46af5b6bf621bb633c262b05b878dbbabdf Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:36:09 +0200 Subject: [PATCH 20/43] 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 bdb745a1bfe5531b60a63a3c445b0576dc02c301 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 02:37:13 +0200 Subject: [PATCH 21/43] 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 c773b0f93faf2a90d7c5d248c8ec2647fa1ffc6b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 15:28:48 +0200 Subject: [PATCH 22/43] 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 f83bc90deb4652afe044f5e814009014dbd10d41 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 15:29:08 +0200 Subject: [PATCH 23/43] 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 a086caac95aba93245697566d3bf396a474646e7 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 15:52:42 +0200 Subject: [PATCH 24/43] 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 430cc31e99d4d2827e590aa33cba6226af5ffadf Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 15:53:18 +0200 Subject: [PATCH 25/43] 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 61ea519f7837a1d097392d01805880daa29a3375 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 15:53:42 +0200 Subject: [PATCH 26/43] 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 8c21f8ac3b00fd31c85e3b4690cc124cd5e21f63 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:21:53 +0200 Subject: [PATCH 27/43] 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 2390f07c04b17bd501a9fc1618dc57f6d7492cb3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:22:26 +0200 Subject: [PATCH 28/43] 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 741e232c112e6938c872c3011e1caf25c766447e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:24:34 +0200 Subject: [PATCH 29/43] 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 c6b28c6dffa91db2786abf67ecd8a868dd31d928 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:26:51 +0200 Subject: [PATCH 30/43] 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 6813df4333b1d8a4b975be6346ad1884e956aaeb Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:33:47 +0200 Subject: [PATCH 31/43] 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 629be3aadbe6ec4e75e2b181eb0b35f1bc17b1af Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:36:27 +0200 Subject: [PATCH 32/43] 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 da58bab1ae1e89bb55955c6a1ed5fd4aea4dbeb5 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:37:45 +0200 Subject: [PATCH 33/43] 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 026055f9a44bbd52941aae2dadb18abf8b586c2c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:40:05 +0200 Subject: [PATCH 34/43] 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 0ea69905c53de49ab8feef9f51e9d1ad3f23c6fd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:55:18 +0200 Subject: [PATCH 35/43] 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 12e8ca9f1c6a8f350063670fbbdd9498c88f7d1c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 17:18:50 +0200 Subject: [PATCH 36/43] 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 6aabc822d428b79a8da7c5850d4e5c1df0e8d019 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 17:19:09 +0200 Subject: [PATCH 37/43] 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 7f4cadc39db33c71e9f79234b7d133be406f8710 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:50:03 +0200 Subject: [PATCH 38/43] 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 5da6c34659f4f20ddd7a3039c2d965a699453a1f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:50:39 +0200 Subject: [PATCH 39/43] 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 913620f7465a978e5018d9487b6cbedef52be9a8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:51:14 +0200 Subject: [PATCH 40/43] 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 63145216150911834e1b3e11f3644327892ddda0 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:51:40 +0200 Subject: [PATCH 41/43] 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 3c71e08586d2a30d46a133b987acb41897d9c988 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:52:04 +0200 Subject: [PATCH 42/43] 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 a3e392075b108d0a7758feaf1085c5bca98f8bd9 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 20:52:30 +0200 Subject: [PATCH 43/43] 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)