Skip to content

feat(mcp): CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, GCP - #1495

Merged
cristim merged 83 commits into
mainfrom
docs/mcp-server-design
Jul 28, 2026
Merged

cristim merged 83 commits into
mainfrom
docs/mcp-server-design

Conversation

@cristim

@cristim cristim commented Jul 22, 2026 •

Copy link
Copy Markdown
Member

Summary

Implements the CUDly MCP server (#1488): a local MCP server exposing CUDly's RI/SP/CUD search and purchase surface across AWS, Azure, and GCP to any MCP client. Supersedes the design-only PR that previously occupied this slot; the design doc (docs/design/mcp-server.md) has been removed and its findings folded into code comments and the new mcp/README.md.

What landed (fully wired, real-purchase capable)

  • cudly_list_commitment_actions — catalog of every tool, generated from each tool's own descriptor so it can never drift from what's registered.
  • cudly_search_recommendations — read-only wrapper over Provider.GetRecommendationsClient().GetRecommendations().
  • cudly_aws_ec2_ri_purchase
  • cudly_aws_opensearch_ri_purchase, cudly_aws_redshift_ri_purchase, cudly_aws_memorydb_ri_purchase
  • cudly_aws_rds_ri_purchase, cudly_aws_elasticache_ri_purchase
  • cudly_aws_savingsplans_purchase
  • cudly_azure_compute_ri_purchase
  • cudly_gcp_computeengine_cud_purchase

All nine purchase tools share one safety gate (mcp/tools/purchase.go): dry_run defaults true and never contacts the cloud provider; a real purchase requires dry_run=false AND confirm=true, otherwise a structured error is returned (never a silent no-op). Every real purchase is stamped with a new common.PurchaseSourceMCP enum value (never a caller-suppliable string) and a deterministic idempotency token, so a retried identical call dedupes at the provider instead of double-buying.

Findings that changed scope from the design doc

Nothing deferred to dry-run-only

Every provider ended up real-purchase capable. Two pre-existing gaps were flagged (not fixed, out of scope) in the affected tool's description and mcp/README.md:

  • Azure's purchase body never sends a billingPlanType, so every Azure VM RI purchase uses Azure's default (upfront) billing plan regardless of the payment_option requested — it only affects the displayed cost estimate.
  • GCP Compute Engine CUDs are a vCPU+memory commitment, not an instance count — cudly_gcp_computeengine_cud_purchase takes vcpu_count/memory_gb directly.

Verification

  • go build ./..., go test ./... (6087+ tests across 41 packages), go vet ./... all green at every commit.
  • gocyclo -over 10 clean on every new file.
  • CI-pinned golangci-lint v2.10.1 (matching ci.yml, not the newer local default) run explicitly: 0 issues, repo-wide.
  • gosec clean on every commit via the pre-commit hook.
  • An end-to-end test (mcp/server_test.go) drives the real MCP protocol over an in-memory transport: connects a real client to the real server, lists tools, and calls cudly_aws_ec2_ri_purchase with dry_run omitted, proving the default-true dry-run path returns structured cost JSON with zero AWS credentials configured and zero provider calls made.

Closes #1488

Summary by CodeRabbit

  • New Features
    • Added local desktop MCP stdio server (cudly-mcp) with discoverable tools for recommendation search and preview-to-execute purchases across AWS, Azure, and GCP, plus a commitment-action catalog.
    • Added new purchase tools for RI/Savings Plans/CUD flows and stronger input schemas and enum validation.
  • Documentation
    • Added setup/usage guidance, including dry-run/confirm safety and real-purchase opt-in rules.
  • Bug Fixes
    • Improved Azure billing-plan mapping and fail-early validation; prevented protocol-breaking warnings; refined AWS recommendation region filtering and search fan-out behavior.
  • Tests
    • Expanded coverage for schemas, gating/confirm logic, idempotency, search fan-out, and purchase execution paths.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/internal Team-internal only effort/l Weeks type/docs Documentation labels Jul 22, 2026
@cristim

cristim commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08008f25-afd8-49be-b663-7c25808bdd9c

📥 Commits

Reviewing files that changed from the base of the PR and between a1d2696 and 707cd14.

📒 Files selected for processing (5)
  • mcp/README.md
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/idempotency_scope_test.go
  • mcp/tools/purchase.go
  • mcp/tools/search_recommendations.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/search_recommendations.go
  • mcp/tools/purchase.go
  • mcp/README.md

📝 Walkthrough

Walkthrough

The PR adds a local stdio MCP server exposing cross-cloud recommendation search, commitment purchase tools, safety-gated execution, action discovery, provider integrations, Azure billing-plan mapping, tests, and setup documentation.

Changes

MCP commitment management

Layer / File(s) Summary
Shared contracts and purchase execution
mcp/tools/registry.go, mcp/tools/schema.go, mcp/tools/enums.go, mcp/tools/purchase.go, mcp/tools/list_commitment_actions.go, pkg/common/types.go
Defines tool metadata, schema refinement, strict validation, action discovery, dry-run and confirmation gates, credential scoping, deterministic idempotency, response mapping, and MCP purchase-source normalization.
Provider commitment tools and search
mcp/tools/aws_*.go, mcp/tools/azure_compute_ri.go, mcp/tools/gcp_computeengine_cud.go, mcp/tools/search_recommendations.go
Adds AWS RI/Savings Plan tools, Azure Compute RI, GCP Compute Engine CUD, and cross-provider recommendation search with provider-specific validation, filters, defaults, schemas, and credential overrides.
Server wiring and provider behavior
mcp/server.go, cmd/cudly-mcp/*, providers/aws/*, providers/azure/services/*
Registers tools on the versioned stdio server, normalizes AWS recommendation regions, keeps protocol diagnostics off stdout, and maps validated payment options to Azure reservation billing plans before side effects.
Validation, documentation, and dependencies
*_test.go, README.md, mcp/README.md, go.mod
Adds unit, protocol, integration, and provider request coverage; documents setup and safety behavior; and adds MCP SDK and JSON-schema dependencies.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant MCPServer
  participant ToolHandler
  participant ProviderClient

  MCPClient->>MCPServer: Invoke search or purchase tool
  MCPServer->>ToolHandler: Dispatch registered tool
  ToolHandler->>ToolHandler: Validate arguments and safety flags

  alt Dry-run or unconfirmed
    ToolHandler-->>MCPClient: Return preview or refusal
  else Confirmed real purchase
    ToolHandler->>ProviderClient: Resolve credentials and client
    ToolHandler->>ProviderClient: Purchase commitment with idempotency token
    ProviderClient-->>ToolHandler: Return purchase result or error
    ToolHandler-->>MCPClient: Return structured response
  end
Loading

Possibly related issues

  • #1502: Adds related Azure billing-plan wiring and preserves rejection of unsupported partial-upfront purchases.
  • #1506: Adds related Savings Plans search defaults, validation, and region-filtering behavior.
  • #1586: Relates to AWS recommendation parser behavior consumed by the MCP search path.

Possibly related PRs

Suggested labels: type/security

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and matches the main change: adding an MCP server for RI/SP/CUD purchases across AWS, Azure, and GCP.
Linked Issues check ✅ Passed The PR adds the MCP server, per-provider tools, safety rails, action catalog, docs, and tests requested by #1488.
Out of Scope Changes check ✅ Passed The changes stay focused on MCP server functionality, provider wiring, safety rails, docs, and tests; no unrelated features stand out.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/mcp-server-design

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim force-pushed the docs/mcp-server-design branch from 8b88ce3 to 6581b27 Compare July 23, 2026 00:01
@cristim cristim removed the type/docs Documentation label Jul 23, 2026
@cristim cristim changed the title docs(mcp): architecture blueprint for CUDly MCP server feat(mcp): CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, GCP Jul 23, 2026
@cristim cristim added the type/feat New capability label Jul 23, 2026
@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
mcp/tools/azure_compute_ri.go (1)

135-141: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared dry_run/confirm resolution helper. Both files reimplement the identical dryRun, confirm = true, false; if args.DryRun != nil {...}; if args.Confirm != nil {...} block for resolving optional *bool args with defaults.

  • mcp/tools/azure_compute_ri.go#L135-L141: replace with a call to a shared helper, e.g. dryRun, confirm = resolveDryRunConfirm(args.DryRun, args.Confirm).
  • mcp/tools/gcp_computeengine_cud.go#L142-L148: replace with the same shared helper call.
♻️ Proposed shared helper
// in a shared tools file, e.g. registry.go
func resolveDryRunConfirm(dryRun, confirm *bool) (bool, bool) {
	d, c := true, false
	if dryRun != nil {
		d = *dryRun
	}
	if confirm != nil {
		c = *confirm
	}
	return d, c
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/azure_compute_ri.go` around lines 135 - 141, The dryRun/confirm
default resolution is duplicated across both tool implementations. Add a shared
resolveDryRunConfirm helper in the tools package that preserves defaults of true
and false while handling optional pointers, then replace the inline blocks in
mcp/tools/azure_compute_ri.go lines 135-141 and
mcp/tools/gcp_computeengine_cud.go lines 142-148 with calls to it.
mcp/tools/aws_ec2_ri.go (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Dry_run/confirm default resolution is duplicated 5 times. Each purchase tool independently re-implements "dry_run defaults to true, confirm defaults to false, resolved from *bool pointers" instead of sharing one generic helper — the root cause is that effectiveDryRunConfirm in mcp/tools/aws_ec2_ri.go is typed to ec2RIPurchaseArgs and can't be reused elsewhere.

  • mcp/tools/aws_ec2_ri.go#L143-156: generalize effectiveDryRunConfirm to take (dryRun, confirm *bool) (bool, bool) and move it to a shared file (e.g. purchase.go) so all tools can call it.
  • mcp/tools/aws_elasticache_ri.go#L145-151: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_rds_ri.go#L152-158: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_savingsplans.go#L162-168: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_simple_ri.go#L180-186: replace the inline block with a call to the shared helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_ec2_ri.go` at line 1, Generalize effectiveDryRunConfirm to
accept dryRun and confirm *bool, returning resolved defaults of true and false,
and move it into a shared purchase helper file. Replace the duplicated
resolution blocks in the ElastiCache, RDS, Savings Plans, and Simple RI purchase
tools with calls to this shared helper, while preserving their existing
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcp/README.md`:
- Around line 9-13: Update the build and launch instructions in the MCP README
to use go install ./cmd/cudly-mcp instead of go build -o cudly-mcp, then
instruct users to launch cudly-mcp from PATH; apply the same change to the
referenced duplicate section.

In `@mcp/tools/aws_savingsplans.go`:
- Around line 70-79: The Register method currently permits unsupported term and
payment combinations for SPTypeDatabase; add a Database-specific validation
branch before building the recommendation that accepts only TermOneYear and
PaymentOptionNoUpfront, rejecting 3-year or upfront payment options while
preserving existing validation for other savings plan types.

In `@mcp/tools/aws_simple_ri.go`:
- Around line 91-105: Add a human-readable display-name field to the RI tool
specification and use it in simpleAWSRIPurchaseTool.Descriptor when formatting
the purchase description, while retaining t.spec.product for machine-readable
metadata such as Product. Populate display names with the correctly cased AWS
product names.

In `@mcp/tools/azure_compute_ri.go`:
- Around line 15-27: Update the Azure compute RI purchase flow using
azureComputeRIPurchaseDescription so non-default payment_option values are
rejected when dry_run=false and confirm=true. Preserve those options for dry-run
validation, and retain the existing behavior for the default upfront option
until the purchase body supports billingPlanType.

In `@mcp/tools/search_recommendations.go`:
- Around line 25-38: Add IncludeRegions and ExcludeRegions fields to
searchRecommendationsArgs with appropriate JSON/schema metadata, then update the
search recommendations handler to forward both values into
common.RecommendationParams. Preserve the existing behavior when either filter
is omitted.

---

Nitpick comments:
In `@mcp/tools/aws_ec2_ri.go`:
- Line 1: Generalize effectiveDryRunConfirm to accept dryRun and confirm *bool,
returning resolved defaults of true and false, and move it into a shared
purchase helper file. Replace the duplicated resolution blocks in the
ElastiCache, RDS, Savings Plans, and Simple RI purchase tools with calls to this
shared helper, while preserving their existing behavior.

In `@mcp/tools/azure_compute_ri.go`:
- Around line 135-141: The dryRun/confirm default resolution is duplicated
across both tool implementations. Add a shared resolveDryRunConfirm helper in
the tools package that preserves defaults of true and false while handling
optional pointers, then replace the inline blocks in
mcp/tools/azure_compute_ri.go lines 135-141 and
mcp/tools/gcp_computeengine_cud.go lines 142-148 with calls to it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 13bba9cf-49e2-462b-a91a-bc9f2d0dcc55

📥 Commits

Reviewing files that changed from the base of the PR and between 70c9a7b and 6581b27.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (32)
  • cmd/cudly-mcp/main.go
  • go.mod
  • mcp/README.md
  • mcp/server.go
  • mcp/server_test.go
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_ec2_ri_test.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/aws_elasticache_ri_test.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_rds_ri_test.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_savingsplans_test.go
  • mcp/tools/aws_simple_ri.go
  • mcp/tools/aws_simple_ri_test.go
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/azure_compute_ri_test.go
  • mcp/tools/enums.go
  • mcp/tools/enums_test.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/gcp_computeengine_cud_test.go
  • mcp/tools/list_commitment_actions.go
  • mcp/tools/list_commitment_actions_test.go
  • mcp/tools/purchase.go
  • mcp/tools/purchase_test.go
  • mcp/tools/registry.go
  • mcp/tools/schema.go
  • mcp/tools/schema_test.go
  • mcp/tools/search_recommendations.go
  • mcp/tools/search_recommendations_test.go
  • pkg/common/types.go
  • pkg/common/types_test.go

Comment thread mcp/README.md
Comment thread mcp/tools/aws_savingsplans.go
Comment thread mcp/tools/aws_simple_ri.go
Comment thread mcp/tools/azure_compute_ri.go Outdated
Comment thread mcp/tools/search_recommendations.go
@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 23 minutes.

@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
mcp/tools/aws_ec2_ri.go (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared dry_run/confirm defaulting logic instead of duplicating it five times. The same dryRun, confirm = true, false; if args.DryRun != nil {...}; if args.Confirm != nil {...} pattern is re-implemented identically in every AWS purchase tool file. Since this logic gates real-money purchases, keeping it in one place reduces the risk of the copies drifting out of sync.

  • mcp/tools/aws_ec2_ri.go#L143-156: generalize effectiveDryRunConfirm into a shared helper (e.g. resolveDryRunConfirm(dryRun, confirm *bool) (bool, bool)) taking the two *bool pointers directly instead of the ec2RIPurchaseArgs struct, and call it from here.
  • mcp/tools/aws_elasticache_ri.go#L145-152: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_rds_ri.go#L152-159: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_savingsplans.go#L180-187: replace the inline block with a call to the shared helper.
  • mcp/tools/aws_simple_ri.go#L184-191: replace the inline block with a call to the shared helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_ec2_ri.go` at line 1, Extract the duplicated dryRun/confirm
defaulting into a shared resolveDryRunConfirm(dryRun, confirm *bool) helper,
preserving defaults of true and false and applying non-nil overrides. Refactor
effectiveDryRunConfirm in the EC2 purchase flow to use this pointer-based
helper, then replace the equivalent inline blocks in the ElastiCache RI, RDS RI,
Savings Plans, and Simple RI purchase flows with calls to it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcp/README.md`:
- Around line 65-67: Update the MCP client registration guidance to state that
when GOBIN is unset, users must use $(go env GOPATH)/bin/cudly-mcp; remove the
misleading implication that $(go env GOBIN)/cudly-mcp is valid in that case.
Preserve the absolute-path guidance for configurations where GOBIN is set.

In `@mcp/server.go`:
- Around line 48-55: Update the registration flow around registrations(),
descriptors, and listTool so the cudly_list_commitment_actions descriptor is
included in its own catalog. Add the catalog tool’s descriptor to descriptors
before constructing listTool, while preserving the final registration list and
existing descriptor order.

In `@mcp/tools/aws_savingsplans.go`:
- Around line 133-139: Update the EC2Instance validation in the flow containing
validateDatabaseSPConstraints to require args.InstanceFamily in addition to
args.Region, returning a validation error when it is empty before proceeding
with purchase or constraint validation. Preserve existing validation behavior
for other savings plan types.

In `@mcp/tools/azure_compute_ri.go`:
- Around line 114-121: Reject whitespace-only required inputs at the validation
boundaries: update the Azure validation in mcp/tools/azure_compute_ri.go lines
114-121 and the GCP validation in mcp/tools/gcp_computeengine_cud.go lines
98-111 to trim-check region plus VMSize or machine_type while preserving
existing errors; add regression cases for whitespace-only values in
mcp/tools/azure_compute_ri_test.go lines 43-64 and
mcp/tools/gcp_computeengine_cud_test.go lines 46-69.

In `@mcp/tools/purchase.go`:
- Around line 76-87: Populate PurchaseResponse.TermYears in both the preview and
real-purchase branches of ExecutePurchase using the requested purchase term,
ensuring term_years is present in responses when applicable; otherwise remove
TermYears and its JSON contract if the value is not intended to be exposed.

---

Nitpick comments:
In `@mcp/tools/aws_ec2_ri.go`:
- Line 1: Extract the duplicated dryRun/confirm defaulting into a shared
resolveDryRunConfirm(dryRun, confirm *bool) helper, preserving defaults of true
and false and applying non-nil overrides. Refactor effectiveDryRunConfirm in the
EC2 purchase flow to use this pointer-based helper, then replace the equivalent
inline blocks in the ElastiCache RI, RDS RI, Savings Plans, and Simple RI
purchase flows with calls to it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d6ca4bc7-4a19-41dc-849d-8311bfdb978d

📥 Commits

Reviewing files that changed from the base of the PR and between 70c9a7b and f83bc90.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (33)
  • README.md
  • cmd/cudly-mcp/main.go
  • go.mod
  • mcp/README.md
  • mcp/server.go
  • mcp/server_test.go
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_ec2_ri_test.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/aws_elasticache_ri_test.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_rds_ri_test.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_savingsplans_test.go
  • mcp/tools/aws_simple_ri.go
  • mcp/tools/aws_simple_ri_test.go
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/azure_compute_ri_test.go
  • mcp/tools/enums.go
  • mcp/tools/enums_test.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/gcp_computeengine_cud_test.go
  • mcp/tools/list_commitment_actions.go
  • mcp/tools/list_commitment_actions_test.go
  • mcp/tools/purchase.go
  • mcp/tools/purchase_test.go
  • mcp/tools/registry.go
  • mcp/tools/schema.go
  • mcp/tools/schema_test.go
  • mcp/tools/search_recommendations.go
  • mcp/tools/search_recommendations_test.go
  • pkg/common/types.go
  • pkg/common/types_test.go

Comment thread mcp/README.md Outdated
Comment thread mcp/server.go
Comment thread mcp/tools/aws_savingsplans.go Outdated
Comment thread mcp/tools/azure_compute_ri.go Outdated
Comment thread mcp/tools/purchase.go
@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 34 minutes.

@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

Adversarial review follow-up (Opus pass)

An independent adversarial review of this PR turned up two defects beyond what CodeRabbit's automated pass flagged. Both are fixed in the commits pushed just now.

DEFECT 1 (HIGH, confirmed at runtime): provider factories were never registered for the MCP binary

providers/aws, providers/azure, and providers/gcp each register their factory via init() in the package root (e.g. providers/aws/provider.go:495). cmd/main.go:21-23 blank-imports all three root packages so the CLI binary gets them; cmd/cudly-mcp/main.go did not, and nothing else in the mcp package's import graph pulled them in either.

Verified with go list -deps ./cmd/cudly-mcp before the fix: the provider root packages never appear in the dependency closure. At runtime this meant provider.CreateProvider("aws"|"azure"|"gcp") always returned "provider %s not registered", so every cudly_search_recommendations call and every real purchase (dry_run=false, confirm=true) failed immediately at the ResolveClient step. Only dry-run previews worked; the server's core purpose (real purchases) was completely non-functional.

Fix: added the same three blank imports to cmd/cudly-mcp/main.go that cmd/main.go already carries.

Regression test: added cmd/cudly-mcp/main_test.go, in package main specifically, because go test ./mcp/... cannot catch this bug at all -- that test binary never pulls in cmd/cudly-mcp's import graph, so the provider registration gap is invisible from mcp/'s own tests. The new test drives a real cudly_aws_ec2_ri_purchase call with dry_run=false, confirm=true through the actual server (real provider registry, not the fake createProvider test seam used elsewhere), with ambient AWS credentials/config fully isolated via t.Setenv so it's deterministic and makes no network call either locally or in CI. It asserts the resulting error is a credentials/not-configured error, never "not registered". Confirmed this test fails pre-fix (reproducing the exact bug) and passes post-fix.

DEFECT 2 (MEDIUM): idempotency key collided across genuinely distinct repeat purchases

mcp/tools/purchase.go's idempotencyKeyFor derived the token purely from purchase parameters (provider/region/service/resource/count/term/payment/details). Two distinct, intentional purchases with identical parameters -- e.g. "buy 3 m5.large RIs now" and "buy 3 more m5.large RIs next week" -- hashed to the same token. findRIByIdempotencyToken (AWS EC2 client) treats a matching existing tag as "this is a retry of an already-completed purchase" and silently skips the second buy. The user believes they own 6 RIs; they own 3. Nothing surfaces the discrepancy.

Fix: the idempotency key now folds in a discriminator that is either an explicit caller-supplied idempotency_nonce (new optional arg on every purchase tool) or, when omitted, an automatic hourly time bucket. This preserves the original retry-safety behavior (a rapid client-side retry after a network timeout, with no nonce, still lands in the same bucket and dedupes as before) while ensuring two genuinely separate purchases made hours/days/weeks apart no longer collide by default. A caller who wants strict, long-lived dedup across an arbitrary gap can pass the same idempotency_nonce value on both calls to force a match regardless of elapsed time.

Added test coverage in mcp/tools/purchase_test.go proving: same nonce -> same key across different times; no nonce + different time buckets -> different keys; no nonce + same bucket -> same key (rapid-retry safety preserved). Updated mcp/README.md's safety-model and troubleshooting sections to describe the new behavior accurately.

Both fixes keep the rest of the money-path hygiene this PR already had intact (dry-run fail-safe, typed-enum validation, nullable cost fields, no PII in logs) -- nothing there needed to change.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

@cristim: Thanks for the detailed adversarial findings and the targeted regression coverage. I’ll review the newly pushed provider-registration and idempotency changes.

🐇🔍


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-23T14:41:10.563Z
Learning: In this Go repository, `providers/aws`, `providers/azure`, and `providers/gcp` register provider factories through package-level `init()` functions. Executable entrypoints that call MCP tools must blank-import all three provider root packages; importing only the `mcp` package does not register them.

Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-23T14:41:10.563Z
Learning: For MCP purchase tools in `mcp/tools`, idempotency must distinguish intentional repeat purchases from retries: callers can supply `idempotency_nonce` for long-lived deduplication, while omitted nonces use an hourly bucket to preserve short retry safety without indefinitely deduplicating equivalent purchases.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcp/tools/idempotency_scope_test.go`:
- Around line 61-77: Extend TestEC2InstanceSavingsPlanStillHonorsRegion to
generate idempotency tokens for equivalent EC2Instance savings-plan arguments in
different regions and assert the tokens differ. Keep the existing recommendation
region assertions, and use the idempotencyKeyFor path to verify region changes
are preserved rather than collapsed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 42e9ea3c-a921-4f4f-b21a-bf5d2dd4aa0b

📥 Commits

Reviewing files that changed from the base of the PR and between c19cff4 and 44b6094.

📒 Files selected for processing (8)
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_simple_ri.go
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/idempotency_scope_test.go
🚧 Files skipped from review as they are similar to previous changes (7)
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_simple_ri.go

Comment thread mcp/tools/idempotency_scope_test.go
…kens

Addresses the CodeRabbit finding on 44b6094. The test's own doc comment
claimed region must still reach "the resolved region and the token" for
EC2Instance plans, but it only asserted rec.Region and details.Region, leaving
the token half of that claim untested.

That gap matters in the opposite direction from the bug the previous commit
fixed: a future change that canonicalized region for EC2Instance too would
keep the field assertions green while collapsing two genuinely different
purchases (m5 in eu-west-1 vs m5 in us-east-1) onto one token, at which point
the second, legitimately distinct purchase would dedupe away and never happen.

Derive tokens for the same plan in two regions and assert they differ.
Confirmed the assertion fails when the region is pinned for every plan type,
so it catches the over-pinning regression rather than restating what the
field assertions already cover.
@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Pushed bdb43cd30, addressing the single actionable on 44b609492.

mcp/tools/idempotency_scope_test.go — assert EC2Instance regions produce different tokens. Valid catch, and the gap was in my own test: its doc comment claimed region must reach "the resolved region and the token", but it only asserted rec.Region and details.Region, leaving the token half of that claim untested.

Worth spelling out why this matters, since it guards the opposite direction from the bug the previous commit fixed. A future change that canonicalized region for EC2Instance too would keep the field assertions green while collapsing two genuinely different purchases (m5 in eu-west-1 vs m5 in us-east-1) onto one token, at which point the second, legitimately distinct purchase would dedupe away and silently never happen.

The test now derives tokens for the same plan in two regions and asserts they differ. Confirmed it FAILS when the region is pinned for every plan type, so it catches the over-pinning regression rather than restating what the field assertions already covered.

CI on 44b609492 was green across all four workflows. Local gates on bdb43cd30: go build ./..., go test ./..., go vet, gocyclo -over 10, and golangci-lint run ./mcp/... all exit 0.

Still open and not mine to land: mcp/tools/purchase.go:578, where a zero result.Timestamp renders a fabricated effective_date (omitempty on a string only drops "").

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline mcp/tools/idempotency_scope_test.go --items all --type function --match 'TestAccountLevelSavingsPlanRegionCannotForkIdempotencyToken|TestEC2InstanceSavingsPlanStillHonorsRegion'
ast-grep outline mcp/tools/aws_savingsplans.go --items all --type function --match 'savingsPlanRecommendationFromArgs'
ast-grep outline mcp/tools/purchase.go --items all --type function --match 'idempotencyKeyFor|detailsKeyComponent|ExecutePurchase|rfc3339OrNil'

printf '\n--- idempotency test ---\n'
sed -n '1,260p' mcp/tools/idempotency_scope_test.go
printf '\n--- savings plans recommendation parsing ---\n'
rg -n -A150 -B20 'func savingsPlanRecommendationFromArgs' mcp/tools/aws_savingsplans.go
printf '\n--- token and timestamp shaping ---\n'
rg -n -A180 -B25 'func idempotencyKeyFor|func detailsKeyComponent|func rfc3339OrNil|EffectiveDate' mcp/tools/purchase.go
printf '\n--- current commit diff ---\n'
git show --format=fuller --stat --find-renames bdb43cd30
git show --format= --find-renames bdb43cd30 -- mcp/tools/idempotency_scope_test.go

Length of output: 45839


@cristim Reviewed bdb43cd30. The added assertion correctly covers the missing identity invariant: otherwise-identical EC2Instance Savings Plans in distinct regions derive different idempotency keys, while account-level plans remain canonicalized separately.

Also, the stale effective_date note no longer applies to the current head: PurchaseResponse.EffectiveDate is nullable and rfc3339OrNil omits an unset provider timestamp rather than emitting year 1.

No new actionable findings from this change.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Understood — triggering a full review of the current PR head rather than an incremental pass.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 39 minutes.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Understood — triggering a full review of the current PR head rather than an incremental pass.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 19 minutes.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Understood — triggering a full review of the current PR head rather than an incremental pass.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
mcp/tools/purchase.go (1)

462-477: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Only ComputeDetails has a value-type case; the other three silently fall through to "".

If any tool ever passes common.DatabaseDetails/CacheDetails/SavingsPlanDetails by value (as ComputeDetails apparently already is somewhere), the type switch hits default and drops every service-specific dimension from the key — a silent token collision on a money path, exactly what this function exists to prevent. Adding the value cases makes the behavior uniform.

♻️ Proposed change
 	case *common.DatabaseDetails:
 		return databaseDetailsKey(d)
+	case common.DatabaseDetails:
+		return databaseDetailsKey(&d)
 	case *common.CacheDetails:
 		return cacheDetailsKey(d)
+	case common.CacheDetails:
+		return cacheDetailsKey(&d)
 	case *common.SavingsPlanDetails:
 		return savingsPlanDetailsKey(d)
+	case common.SavingsPlanDetails:
+		return savingsPlanDetailsKey(&d)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/purchase.go` around lines 462 - 477, Update detailsKeyComponent to
handle value-type common.DatabaseDetails, common.CacheDetails, and
common.SavingsPlanDetails cases alongside their pointer cases. Pass each value
case by address to its existing key helper, preserving the current pointer
behavior and preventing service-specific dimensions from being dropped.
mcp/server_test.go (1)

44-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stray doc comment: the TestRealPurchaseToolsDocumentMoneyImpactAndDryRun block is attached to TestEndToEndSearchThenDryRunPurchase.

Lines 44-49 document a different test (defined at line 158, which now has no doc comment). Move that paragraph above its own function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/server_test.go` around lines 44 - 61, Move the documentation paragraph
beginning with TestRealPurchaseToolsDocumentMoneyImpactAndDryRun above the
TestRealPurchaseToolsDocumentMoneyImpactAndDryRun function, and leave the
TestEndToEndSearchThenDryRunPurchase comment describing only the end-to-end
test. Ensure the latter test’s existing documentation remains directly attached
to its function.
mcp/tools/aws_ec2_ri.go (1)

68-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

platform is missing the Enum/Default schema override that tenancy/scope get.

platform has the same shape as tenancy/scope (small SDK-backed enum + documented default), but only those two are advertised via FieldOverride in the schema. An MCP client can't discover the 4 valid platform values without a round trip that fails validation.

♻️ Proposed fix
 	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)}},
+		"platform": {Enum: []any{
+			string(ec2types.RIProductDescriptionLinuxUnix),
+			string(ec2types.RIProductDescriptionLinuxUnixAmazonVpc),
+			string(ec2types.RIProductDescriptionWindows),
+			string(ec2types.RIProductDescriptionWindowsAmazonVpc),
+		}, Default: string(ec2types.RIProductDescriptionLinuxUnix)},
 		"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},
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_ec2_ri.go` around lines 68 - 78, Update the BuildInputSchema
overrides for ec2RIPurchaseArgs to include platform with the SDK’s four valid
platform enum values and its documented default, matching the existing scope and
tenancy overrides. Use the existing platform constants and preserve all other
schema overrides unchanged.
mcp/tools/gcp_computeengine_cud_test.go (1)

48-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for the explicit-project requirement.

The PR objectives call out that GCP real purchases require an explicit project, but the invalid-args table never mutates GCPProjectID. A blank/whitespace gcp_project_id case would pin that requirement here.

💚 Suggested additional cases
 		{"invalid term", func(a *gcpComputeEngineCUDPurchaseArgs) { a.TermYears = 2 }, "invalid term_years"},
+		{"missing gcp_project_id", func(a *gcpComputeEngineCUDPurchaseArgs) { a.GCPProjectID = "" }, "gcp_project_id"},
+		{"whitespace-only gcp_project_id", func(a *gcpComputeEngineCUDPurchaseArgs) { a.GCPProjectID = "  " }, "gcp_project_id"},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/gcp_computeengine_cud_test.go` around lines 48 - 73, Add an invalid
case to TestGCPComputeEngineRecommendationFromArgsInvalid that mutates
GCPProjectID to blank or whitespace and expects the gcp_project_id-required
validation error, preserving the existing table-driven test pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcp/tools/search_recommendations.go`:
- Around line 412-419: Update trimSearchArgsIdentifiers to trim the credential
override fields aws_profile, azure_subscription_id, and gcp_project_id before
returning args, ensuring providerConfigFromArgs receives normalized values like
the existing region fields.

In `@providers/azure/services/internal/reservations/purchase.go`:
- Around line 105-121: Update the default branch of BillingPlanForPaymentOption
so the error message describes only the actual unsupported paymentOption value;
mention partial-upfront’s lack of an Azure equivalent only when that specific
value is supplied, without attributing it to unrelated inputs.

---

Nitpick comments:
In `@mcp/server_test.go`:
- Around line 44-61: Move the documentation paragraph beginning with
TestRealPurchaseToolsDocumentMoneyImpactAndDryRun above the
TestRealPurchaseToolsDocumentMoneyImpactAndDryRun function, and leave the
TestEndToEndSearchThenDryRunPurchase comment describing only the end-to-end
test. Ensure the latter test’s existing documentation remains directly attached
to its function.

In `@mcp/tools/aws_ec2_ri.go`:
- Around line 68-78: Update the BuildInputSchema overrides for ec2RIPurchaseArgs
to include platform with the SDK’s four valid platform enum values and its
documented default, matching the existing scope and tenancy overrides. Use the
existing platform constants and preserve all other schema overrides unchanged.

In `@mcp/tools/gcp_computeengine_cud_test.go`:
- Around line 48-73: Add an invalid case to
TestGCPComputeEngineRecommendationFromArgsInvalid that mutates GCPProjectID to
blank or whitespace and expects the gcp_project_id-required validation error,
preserving the existing table-driven test pattern.

In `@mcp/tools/purchase.go`:
- Around line 462-477: Update detailsKeyComponent to handle value-type
common.DatabaseDetails, common.CacheDetails, and common.SavingsPlanDetails cases
alongside their pointer cases. Pass each value case by address to its existing
key helper, preserving the current pointer behavior and preventing
service-specific dimensions from being dropped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: df0c93bb-dc6e-4ff4-bd3c-9ce6cc4bc7a0

📥 Commits

Reviewing files that changed from the base of the PR and between 7b18bfd and bdb43cd.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (60)
  • README.md
  • cmd/cudly-mcp/main.go
  • cmd/cudly-mcp/main_test.go
  • cmd/multi_service_stats.go
  • go.mod
  • mcp/README.md
  • mcp/server.go
  • mcp/server_test.go
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_ec2_ri_test.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/aws_elasticache_ri_test.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_rds_ri_test.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_savingsplans_test.go
  • mcp/tools/aws_simple_ri.go
  • mcp/tools/aws_simple_ri_test.go
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/azure_compute_ri_test.go
  • mcp/tools/enums.go
  • mcp/tools/enums_test.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/gcp_computeengine_cud_test.go
  • mcp/tools/idempotency_scope_test.go
  • mcp/tools/list_commitment_actions.go
  • mcp/tools/list_commitment_actions_test.go
  • mcp/tools/purchase.go
  • mcp/tools/purchase_test.go
  • mcp/tools/registry.go
  • mcp/tools/schema.go
  • mcp/tools/schema_test.go
  • mcp/tools/search_recommendations.go
  • mcp/tools/search_recommendations_fanout_test.go
  • mcp/tools/search_recommendations_test.go
  • pkg/common/archera.go
  • pkg/common/types.go
  • pkg/common/types_test.go
  • providers/aws/recommendations/parser_ri.go
  • providers/aws/recommendations/parser_ri_test.go
  • providers/aws/recommendations/parser_sp.go
  • providers/aws/recommendations/parser_sp_test.go
  • providers/aws/service_client.go
  • providers/aws/service_client_test.go
  • providers/azure/services/cache/client.go
  • providers/azure/services/cache/client_test.go
  • providers/azure/services/compute/client.go
  • providers/azure/services/compute/client_test.go
  • providers/azure/services/cosmosdb/client.go
  • providers/azure/services/cosmosdb/client_test.go
  • providers/azure/services/database/client.go
  • providers/azure/services/database/client_test.go
  • providers/azure/services/internal/reservations/purchase.go
  • providers/azure/services/internal/reservations/purchase_test.go
  • providers/azure/services/managedredis/client.go
  • providers/azure/services/managedredis/client_test.go
  • providers/azure/services/search/client.go
  • providers/azure/services/search/client_test.go
  • providers/azure/services/synapse/client.go
  • providers/azure/services/synapse/client_test.go

Comment thread mcp/tools/search_recommendations.go
Comment thread providers/azure/services/internal/reservations/purchase.go
cristim added a commit that referenced this pull request Jul 28, 2026
#1504)

* fix(azure): coerce web partial-upfront to monthly, not upfront

The web/API purchase path coerced Azure partial-upfront payment options
to upfront before validation. Since PR #1495 wires Azure billingPlan
directly from the normalized payment option, that coercion silently
billed an all-upfront schedule the caller never chose. Coerce to
monthly (no-upfront, CUDly's default Azure schedule) instead, so the
rec still survives validation without flipping the billing schedule.

Closes #1503

* docs(azure): correct payment-option mechanism comment (cost split, not billingPlan)

The comment on NormalizePaymentOption/crossProviderPaymentAlias said the
web/API path wires Azure's billingPlan directly from the normalized
payment option. buildReservationBody in providers/azure/services/compute/client.go
never emits a billingPlan field for VM reservations; the payment option
actually drives the upfront-vs-monthly cost split in that file's
GetOfferingDetails. No behavior change, comment accuracy only.

* fix(api): warn on payment-option normalization coercion

validatePurchaseRecommendation silently overwrote rec.Payment with the
NormalizePaymentOption result, contradicting the doc comment that
callers are expected to WARN when a raw payment option is coerced to
its canonical form (e.g. azure partial-upfront -> monthly). Log the
transition via pkg/logging, matching the pattern already used by
scheduler.convertRecommendations, so the coercion is auditable on this
money-affecting field. The coercion itself is unchanged.

* docs(config): future-proof Azure payment-coercion comments for #1495

The coercion-rationale comments assert the normalized payment option is
"not a billingPlan request field". That is accurate on main today (the
reservation purchase body sends no billingPlan), but becomes false once
the billingPlan wiring lands (#1495/#1502), and that wiring does not
touch this file, so the comment would silently go stale on a money-path
rationale. Rephrase to state the forward-compatible fact instead: the
wiring maps only upfront/monthly tokens and hard-errors on
partial-upfront, which is exactly why normalizing to monthly here keeps
web-path Azure purchases valid.

Comment-only change; no behavior difference.

* feat(api): surface payment-option coercions in the purchase response

The web execute path silently normalizes cross-provider payment tokens
(most notably Azure partial-upfront -> monthly, #1503); until now only
operators saw the WARN log while the caller's response gave no hint the
billing schedule they requested was changed. Return the coercion to the
caller: validatePurchaseRecommendation now also yields a
PaymentAdjustment (rec index, provider, service, requested token,
applied token, reason) whenever normalization changes the token, and
executePurchase attaches the collected list to all three response
bodies (approval-pending, duplicate-collapse, direct-execute) under
payment_adjustments. The key is omitted entirely when every option was
already canonical, so existing clients are unaffected on the common
case, and the coercion policy itself (config.NormalizePaymentOption)
plus the operator WARN log are unchanged.

Tests: the guards table now cross-checks every coerced row surfaces a
matching adjustment and every canonical row surfaces none; new
field-level unit test for the Azure partial-upfront case; new
handler-level tests assert the response body carries the adjustment for
a mixed canonical+coerced batch (verified to fail with the attach
stubbed out) and omits the key for a canonical-only batch, both with
mock.AssertExpectations registered. frontend PurchaseResult gains the
matching optional payment_adjustments field.

* fix(frontend): coerce azure partial-upfront to monthly, not upfront

The Go side of #1503 was fixed to map Azure partial-upfront onto
"monthly", but normalizePaymentValue in the frontend still mapped it
onto "upfront", so the two halves of the web path disagreed.

That function decides which option the plan and purchase dropdowns
pre-select (plans.ts:1166 on plan edit, plans.ts:1562 when prefilling
from a selected commitment), so a rec carrying the legacy
partial-upfront token pre-selected "Pay Upfront". Saving from that
state submits an already-canonical "upfront", which the backend
accepts verbatim, so the backend coercion never gets a chance to run
and the user is committed to a full upfront charge they never chose.

Azure offers exactly two billing plans and the total cost is identical
either way, but the plan cannot be changed after purchase, so landing
on monthly is the only direction that cannot surprise the user with an
irreversible upfront charge.

The jest case that pinned the old expectation is updated and now
documents why monthly is required, mirroring the Go-side assertion in
TestNormalizePaymentOption. Verified failing before this change and
passing after.

Refs #1503

* docs(config): correct azure payment-coercion rationale

The doc comments justifying the partial-upfront to monthly coercion
cited providers/azure/services/compute/client.go's GetOfferingDetails
as the consumer that "drives the upfront-vs-monthly cost split".
GetOfferingDetails has no non-test callers anywhere in the repo; it
exists only to satisfy the ServiceClient interface, so the stated
mechanism does not run. On a money path a wrong rationale is worse
than none, since the next reader reasons from it.

Replace it with what actually consumes the canonical token today
(persisted on the execution, then copied into
common.Recommendation.PaymentOption in internal/purchase/execution.go)
and the billingPlan wiring arriving in #1495/#1502.

Also record the two Microsoft-documented facts the decision rests on:
upfront and monthly cost the same total, and the billing frequency
cannot be changed after purchase. Note the documented exception that
monthly is unavailable for SUSE Linux, Red Hat, Azure Red Hat
OpenShift and pre-purchase plans, where the coerced token makes Azure
reject the purchase; that loud rejection is the intended outcome.

Adds the cross-reference to the frontend mirror of this mapping so the
two stay in lockstep.

Comments only, no behaviour change.

Refs #1503

* fix(frontend): surface azure payment coercion to the user (#1503)

The backend already returns `payment_adjustments` when it normalizes a
requested payment option onto a different provider-canonical token (Azure
has exactly two billing plans, Upfront and Monthly, so an inherited
AWS-style `partial-upfront` has nowhere to land and is coerced to
monthly). The field was declared in `frontend/src/api/types.ts` and read
by nothing, so the coercion was disclosed to API clients and to the
operator WARN log but never to the web user whose billing schedule
actually changed.

Coercing instead of rejecting is only defensible if the user is told, so
this closes that gap:

- Add `formatPaymentAdjustmentNotice` next to `normalizePaymentValue`,
  the mapping it discloses. Reuses the existing `getPaymentLabel` so the
  copy shows "Partial Upfront"/"Pay Monthly" rather than raw API tokens,
  and collapses a batch to its distinct requested -> applied pairs.
- Render it as a separate non-expiring warning toast on both purchase
  submit paths (single and fan-out) so an irreversible billing-schedule
  change cannot scroll away inside a success message. The fan-out path
  collects from every fulfilled response, including buckets whose
  approval email failed, since those still created a pending execution
  carrying the coerced schedule.
- Extract the inline `payment_adjustments` element type into a named
  `PaymentAdjustment` interface so the formatter is typed against the
  same shape the API returns.

Regression coverage: the two disclosure tests in
purchase-execution-toast.test.ts fail with the wiring removed and pass
with it, exercising the real handler through to the rendered toast; a
formatter-only unit test could not prove the notice reaches the user.

* fix(api): disclose payment coercion only when the schedule changes

The #1503 disclosure fired on every raw != canonical rewrite, including
the cross-provider renames that leave the customer's cash flow untouched.
Azure spells AWS's all-upfront "upfront" and AWS's no-upfront "monthly",
so both of those rewrites are bookkeeping, not a billing change.

That mattered on the ordinary path, not an edge case: the fan-out
purchase modal builds its per-bucket Payment dropdown from
paymentOptionsFor (frontend/src/lib/purchase-compatibility.ts), whose
candidate list has no "upfront" entry. An Azure bucket the user chooses
to pay upfront therefore ALWAYS submits "all-upfront", so every ordinary
Azure upfront purchase raised a sticky "Billing schedule adjusted"
warning claiming Azure "does not offer All Upfront" and that the purchase
was applied as something else. Both claims are false, and the noise
trains users to dismiss the one notice that is real.

Classify a payment token by the cash flow it implies
(config.PaymentScheduleFor) and surface a PaymentAdjustment only when
the rewrite crosses schedules (config.PaymentCoercionChangesSchedule).
Azure partial-upfront -> monthly and GCP upfront -> monthly still
disclose; Azure all-upfront -> upfront and no-upfront -> monthly no
longer do.

The operator WARN keeps firing on every rewrite: a non-canonical token
on the wire is an upstream input bug worth auditing even when it costs
the customer nothing. Only the user-facing notice is narrowed.

Adjustment construction moves into paymentAdjustmentFor so
validatePurchaseRecommendation stays at gocyclo 10 (the pre-commit
threshold), not 11.

Regression coverage: the three rename cases assert nil adjustment plus a
still-emitted WARN, and the GCP upfront-shaped tokens assert a surfaced
adjustment. Both new tests fail against the previous raw != canonical
condition and pass after (verified by reverting the condition).
…partial-upfront

Addresses the two unresolved CodeRabbit threads on bdb43cd.

trimSearchArgsIdentifiers normalized service, region, include_regions,
exclude_regions, and account_filter but not the three credential overrides,
so providerConfigFromArgs forwarded aws_profile / azure_subscription_id /
gcp_project_id into ProviderConfig raw. A padded " my-profile " then failed
credential resolution for a reason the resulting error never mentions,
reading as "these credentials are broken" rather than "this name has a stray
space". The purchase tools already normalize the same three fields through
CredentialScope; this closes the gap on the search path.

BillingPlanForPaymentOption's default branch appended "(partial-upfront has
no azure equivalent)" to every unrecognized value, so a typo ("montly") or a
value from another provider's vocabulary ("prepaid") was diagnosed as a
partial-upfront problem the caller never had. Give partial-upfront its own
case -- it is a real payment option elsewhere in CUDly, so the caller does
need to know Azure has no equivalent -- and report anything else as simply
not recognized. This extends the same split an earlier commit already made
for the empty value, for the same reason: an error that names the wrong
cause sends whoever reads it looking in the wrong place.

A dedicated case rather than a conditional inside default, so the switch's
already-lowercased and trimmed value decides it instead of re-normalizing.

Both guards were confirmed to fail against the pre-fix code. The Azure test
also pins that "  Partial-Upfront  " still reaches the partial-upfront case
rather than falling through to the unrecognized branch.
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Pushed a1d269686, addressing both unresolved threads from the bdb43cd30 review.

mcp/tools/search_recommendations.go — credential overrides not trimmed. Confirmed and fixed. trimSearchArgsIdentifiers normalized every other identifier in the file but not aws_profile/azure_subscription_id/gcp_project_id, so providerConfigFromArgs forwarded them raw. Trimmed at that same normalization boundary, which covers the 472-482 site you also flagged since both read from the normalized args. Worth noting this is the search-path twin of the purchase-path gap fixed in 44b609492, where the same three fields now go through CredentialScope.

providers/azure/services/internal/reservations/purchase.go — unrecognized values blamed on partial-upfront. Confirmed and fixed, though I implemented it as a dedicated case "partial-upfront" rather than a conditional inside default: the switch has already lowercased and trimmed its subject, so a separate case decides it without re-normalizing. Behaviour matches your proposal — partial-upfront keeps its specific "no azure equivalent" message because it is a real payment option elsewhere in CUDly, and everything else now reports "is not a recognized payment option". This extends the split an earlier commit already made for the empty value, for the identical reason: an error naming the wrong cause sends whoever reads it looking in the wrong place.

Regression coverage. Both guards were confirmed to FAIL against the pre-fix code and pass after. The Azure test also pins that " Partial-Upfront " still reaches the partial-upfront case rather than falling through to the unrecognized branch, so the normalization the dedicated case relies on is itself asserted.

Local gates on a1d269686: go build ./..., go test ./..., go vet, gocyclo -over 10, and golangci-lint run ./mcp/... all exit 0.

One note on the Azure submodule: golangci-lint reports pre-existing findings there (bodyclose in tests, errcheck, gocritic, and a set of cancelled/canceled misspells). None are on the lines this commit touches, and I deliberately left them alone rather than folding an unrelated cleanup into a CR-fix commit. The misspell ones in particular are not safe to autofix here: cancelled_by is a database column name in this repo, and a blanket rewrite has previously broken integration tests.

Also confirming the mcp/tools/purchase.go effective_date item I raised in earlier rounds is now resolved — EffectiveDate is a *string, so an unpopulated timestamp is omitted rather than shipped as 0001-01-01T00:00:00Z.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- commit summary and changed hunks ---'
git show --format=fuller --stat --find-renames a1d269686
git show --format= --find-renames a1d269686 -- \
  mcp/tools/search_recommendations.go \
  mcp/tools/search_recommendations_test.go \
  providers/azure/services/internal/reservations/purchase.go \
  providers/azure/services/internal/reservations/purchase_test.go

printf '%s\n' '--- relevant source outlines ---'
ast-grep outline mcp/tools/search_recommendations.go --items all --type function --match 'trimSearchArgsIdentifiers|providerConfigFromArgs'
ast-grep outline providers/azure/services/internal/reservations/purchase.go --items all --type function --match 'BillingPlanForPaymentOption'

printf '%s\n' '--- search normalization and configuration ---'
rg -n -A90 -B15 'func trimSearchArgsIdentifiers|func providerConfigFromArgs' \
  mcp/tools/search_recommendations.go

printf '%s\n' '--- Azure billing-plan mapping ---'
rg -n -A90 -B15 'func BillingPlanForPaymentOption' \
  providers/azure/services/internal/reservations/purchase.go

printf '%s\n' '--- focused regression tests ---'
rg -n -C4 'AWSProfile|AzureSubscriptionID|GCPProjectID|Credential|Partial-Upfront|partial-upfront|recognized payment option' \
  mcp/tools/search_recommendations_test.go \
  providers/azure/services/internal/reservations/purchase_test.go

Length of output: 40740


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline mcp/tools/search_recommendations.go --items all --type method,function --match 'handle'
printf '%s\n' '--- handler call order ---'
rg -n -A150 -B20 'func \(t \*searchRecommendationsTool\) handle' \
  mcp/tools/search_recommendations.go

printf '%s\n' '--- commit file list (sanity check) ---'
git diff-tree --no-commit-id --name-status -r a1d269686

printf '%s\n' '--- parent comparison for changed tests ---'
git diff --check a1d269686^ a1d269686

Length of output: 9064


@cristim Verified a1d269686.

Both reported findings are addressed:

  • searchRecommendationsTool.handle normalizes the credential overrides before validation and before providerConfigFromArgs constructs the provider configuration. The new table-driven test verifies the trimmed AWS profile, Azure subscription ID, and GCP project ID at that configuration boundary.
  • BillingPlanForPaymentOption now retains its specific normalized partial-upfront diagnostic while correctly classifying unrelated invalid values as unrecognized. The regression coverage includes padded/mixed-case Partial-Upfront.

The diff is limited to the four stated implementation/test files, and the inspected diff has no whitespace errors. No new actionable findings.


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-28T15:05:02.231Z
Learning: In the Go MCP recommendation-search path in `mcp/tools/search_recommendations.go`, `trimSearchArgsIdentifiers` must trim `aws_profile`, `azure_subscription_id`, and `gcp_project_id` before `providerConfigFromArgs` builds `provider.ProviderConfig`. These credential overrides select the account against which a search runs, so whitespace-padded values must not reach provider configuration.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

ARM subscription IDs are case-insensitive GUIDs, and nothing in
providers/azure canonicalizes them -- resolveAzureSubscriptionID forwards
whatever it is given straight into the request path. CredentialScope
trimmed the value but never folded its case, so the SAME subscription
spelled two ways derived two DIFFERENT idempotency tokens.

The double-spend that follows:

  1. A purchase is issued with
     azure_subscription_id="ABC12345-1234-1234-1234-1234567890AB", the
     spelling the Azure portal hands you, and times out.
  2. The model retries. This time the override is omitted, so the value
     comes from AZURE_SUBSCRIPTION_ID, which conventionally holds the
     lower-case "abc12345-...".
  3. Both calls target the same subscription, but the tokens differ, so
     reservations.FindReservationOrderByIdempotencyToken -- which matches
     on the token tag across the TENANT-wide order list -- misses the
     first order.
  4. A SECOND reservation is purchased.

This is the defect family 44b6094 fixed for untrimmed input: purchase
identity forked by a difference the provider itself does not recognize.
Whitespace was normalized there; case was not.

Both Azure uses of the subscription ID -- the idempotency scope in handle
and the ProviderConfig in resolveClient -- now go through the single
azureCredentialScope helper, so they cannot normalize it differently.
Case folding is deliberately NOT pushed into CredentialScope: AWS named
profiles are case-SENSITIVE section names in ~/.aws/config, so
lower-casing one would point a real purchase at a profile that does not
exist or at a different one that does. GCP project IDs are lower-case by
grammar. The new guard covers both directions -- two cases of one Azure
subscription must derive ONE token, two different subscriptions must keep
two -- and asserts AWS profile case is still preserved end to end, so the
Azure fix cannot leak into the AWS path. It fails on the pre-fix code.

Also in this change:

- cudly_search_recommendations no longer claims it "spends no money".
  Since d76aa7c, a reservation search that omits term_years and
  payment_option fans out to six Cost Explorer requests, and AWS bills
  those per request. The tool buys no commitment, but it is not free, and
  a model told otherwise may loop on it. The description now says both.
- The account-level Savings Plan region guard covers Database alongside
  Compute and SageMaker. It needs its own 1yr/no-upfront fixture because
  validateDatabaseSavingsPlan permits nothing else. Database is
  account-level by construction today (the branch tests for EC2Instance),
  so the case exists to stop a refactor that enumerates the account-level
  types explicitly from quietly dropping it.

Deferred to #1586: parseRecommendations logs and continues when one
recommendation detail fails to parse, so a combo whose best offer fails
quantity/cost parsing vanishes silently -- recreating within a combo the
"5 of 6 offers presented as all your options" defect the fan-out exists
to prevent. The parser is shared with the scheduler's discovery sweep,
which legitimately wants the tolerant behaviour, so the fix needs a
per-caller policy and does not belong on this branch.
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Pushed 707cd14a0 addressing the follow-up review of a1d269686. The independent adversarial pass returned no confirmed findings, so these are the plausible items from it; three are fixed here and one is deferred with an issue.

Fixed

1. Azure subscription-ID case could fork purchase identity (double-spend). CredentialScope trimmed the credential but never folded its case, and azure_compute_ri.go folded the verbatim string into the idempotency token. ARM subscription IDs are case-insensitive GUIDs and nothing in providers/azure canonicalizes them, so:

  • a purchase issued with azure_subscription_id="ABC12345-1234-1234-1234-1234567890AB" (the spelling the portal hands you) times out;
  • the model retries with the override omitted, so the value now comes from AZURE_SUBSCRIPTION_ID as the lower-case abc12345-...;
  • same subscription, two tokens, so reservations.FindReservationOrderByIdempotencyToken (tenant-wide order list, matched on the token tag) misses the first order and a second reservation is purchased.

Same defect family as 44b6094: purchase identity forked by a difference the provider itself does not recognize. Whitespace was normalized there; case was not.

Both Azure uses of the subscription ID -- the idempotency scope in handle and the ProviderConfig in resolveClient -- now route through one azureCredentialScope helper, so they cannot normalize differently. Case folding is deliberately not pushed into CredentialScope: AWS named profiles are case-sensitive section names in ~/.aws/config, so lower-casing one would point a real purchase at a profile that does not exist or at a different one that does. GCP project IDs are lower-case by grammar.

TestAzureSubscriptionCaseCannotForkIdempotencyToken drives real purchases through the tool's own handle() (not a re-derived scope expression) and asserts both directions: two cases of one subscription derive one token, two genuinely different subscriptions keep two. Confirmed it fails on the pre-fix code (462f79c8... vs c10d89a9... for the same subscription) and passes after. TestAWSProfileCaseIsPreserved pins the boundary so the Azure fix cannot leak into the AWS path.

2. cudly_search_recommendations no longer claims it "spends no money". Since d76aa7c, a reservation search that omits term_years and payment_option fans out to six Cost Explorer requests, and AWS bills those per request. The tool buys no commitment, but it is not free, and a model told otherwise may loop on it. The description now states both, and mcp/README.md's fan-out bullet notes the request multiplication.

3. Account-level Savings Plan region guard now covers Database. It needs its own 1yr/no-upfront fixture because validateDatabaseSavingsPlan permits nothing else, which is why the shared 3yr fixture could not carry it. Database is account-level by construction today (the branch tests for EC2Instance), so the case exists to stop a refactor that enumerates the account-level types explicitly from quietly dropping it.

Deferred -- LeanerCloud/cloud-commitments-go#54

parseRecommendations (providers/aws/recommendations/parser_ri.go:30) logs to stderr and continues when one recommendation detail fails to parse. fetchSearchCombos correctly fails the whole search on a combo API error, but a combo whose best offer fails quantity/cost parsing vanishes silently, recreating within a combo the "5 of 6 offers presented as all your options" defect the fan-out exists to prevent. Newly load-bearing now that the tool promises a complete menu, but the parser is shared with the scheduler's discovery sweep, which legitimately wants the tolerant behaviour -- so the fix needs a per-caller policy and does not belong on this branch. Filed as LeanerCloud/cloud-commitments-go#54.

Gates

gate exit
go build ./... 0
go vet ./mcp/... 0
go test ./... (41 pkgs, 28 with tests) 0
gocyclo -over 10 mcp/tools/ mcp/ 0, no output
golangci-lint run ./mcp/... (v2.10.1, the CI-pinned version) 0, 0 issues.

Pre-commit hooks ran clean on the commit (no --no-verify).

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- commit summary and file list ---'
git show --format=fuller --stat --find-renames 707cd14a0
git diff-tree --no-commit-id --name-status -r 707cd14a0

printf '%s\n' '--- changed hunks ---'
git show --format= --find-renames 707cd14a0

printf '%s\n' '--- symbol outlines ---'
for file in \
  mcp/tools/azure_compute_ri.go \
  mcp/tools/azure_compute_ri_test.go \
  mcp/tools/aws_savingsplans_test.go \
  mcp/tools/search_recommendations.go
do
  if [ -f "$file" ]; then
    echo "### $file"
    ast-grep outline "$file" --items all --type function,method --view compact || true
  fi
done

printf '%s\n' '--- identity and canonicalization references ---'
rg -n -C 4 \
  'azureCredentialScope|CredentialScope\(|AzureSubscriptionID|idempotencyKeyFor|TestAzureSubscriptionCaseCannotForkIdempotencyToken|TestAWSProfileCaseIsPreserved|SPTypeDatabase|Database' \
  mcp/tools README.md mcp/README.md 2>/dev/null || true

printf '%s\n' '--- deferred issue ---'
gh issue view 1586 --repo LeanerCloud/CUDly --json number,title,state,body,url

Length of output: 112131


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Azure provider subscription resolution ---'
ast-grep outline providers/azure/provider.go --items all --type function,method --match 'resolveAzureSubscriptionID|NewProvider' --view signatures
rg -n -A90 -B20 'func resolveAzureSubscriptionID|func NewProvider|AZURE_SUBSCRIPTION_ID|AzureSubscriptionID' \
  providers/azure/provider.go

printf '%s\n' '--- Azure MCP purchase tool inventory and registration ---'
rg -n -C 3 \
  'NewAzure|ProviderAzure|AzureSubscriptionID|azure_compute_ri_purchase|cudly_azure' \
  mcp/tools mcp/server.go

printf '%s\n' '--- exact test execution path and environment use ---'
rg -n -A100 -B15 \
  'func azurePurchaseIdentity|func TestAzureSubscriptionCaseCannotForkIdempotencyToken|func \(t \*azureComputeRIPurchaseTool\) handle|func \(t \*azureComputeRIPurchaseTool\) resolveClient' \
  mcp/tools/idempotency_scope_test.go mcp/tools/azure_compute_ri.go

printf '%s\n' '--- diff integrity ---'
git diff --check 707cd14a0^ 707cd14a0

Length of output: 51206


🤖 web_search executed:

AWS Cost Explorer API pricing per request Cost Explorer API requests pricing

Length of output: 6806


@cristim Reviewed 707cd14a0; no new actionable findings.

  • azureCredentialScope correctly applies Azure-only case normalization to both the idempotency scope and ProviderConfig.AzureSubscriptionID. This covers the sole Azure MCP purchase tool without changing AWS’s case-sensitive profile semantics.
  • The Azure regression test verifies same-subscription case variants dedupe, distinct subscriptions remain distinct, and the provider configuration receives the canonical form.
  • The Database Savings Plans case uses its required 1yr/no-upfront combination and correctly extends the account-level region/idempotency invariant.
  • The search description and MCP README now accurately distinguish “does not buy a commitment” from “has no cost.” AWS documents Cost Explorer API requests as billable, including paginated requests.
  • #1586 is open with a clear strict-MCP versus tolerant-scheduler follow-up design and test direction.

🐇✅


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1495

Timestamp: 2026-07-28T16:04:28.563Z
Learning: For the Azure MCP purchase path in `mcp/tools/azure_compute_ri.go`, Azure subscription IDs must be canonicalized with Azure-specific lower-casing through `azureCredentialScope` for both `PurchaseRequest.CredentialScope` and `provider.ProviderConfig.AzureSubscriptionID`. Azure ARM subscription IDs are case-insensitive, while AWS profile names remain case-sensitive and must not be normalized this way; aligning token scope and provider configuration prevents case-only retries from bypassing Azure idempotency.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Re-requesting review: the latest commit on this PR postdates your last review, so that review does not cover the current HEAD.

Fixes landed since then were verified locally (build, tests, vet, gocyclo, and golangci-lint at the CI-pinned version), and CI on this PR is green across all workflows. Please review the current HEAD.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Understood — reviewing the current PR HEAD, including the commits added since the previous review.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 887d51f into main Jul 28, 2026
19 checks passed
cristim added a commit that referenced this pull request Jul 28, 2026
An Azure exchange mutates two sets of reservations: the ones handed back
(sources) and the ones acquired (targets). Only the targets reached the
Regions dimension of the execute:ri-exchange constraint check, so a
caller permitted solely in eastus could POST an exchange whose source was
their own subscription's westeurope Standard_M128 3-year reservation and
whose target was a single eastus reservation. targetLocations returned
["eastus"], the region constraint passed, requireAzureSourceOwnership
passed (the westeurope reservation IS billed to that subscription, and
that gate keys on BillingScopeID rather than region), and the exchange
committed: a westeurope commitment the caller was never authorized to
touch was consumed and relocated to eastus, irreversibly.

No other gate covered it -- ExchangeableReservation.Region was consulted
by nothing. The AWS analog is safe only because AWS exchanges are
same-region; cross-region is this feature's stated purpose.

exchangeRegions now folds each source reservation's own region into the
same normalized, de-duplicated set as the target locations, so every
region the operation touches must be permitted. Source regions come from
the tenant listing rather than the request body, which names only
reservation ids; the listing is fetched once in
authorizeAzureExchangeExecution and reused by requireAzureSourceOwnership
so both source-side gates judge the same data. That listing now precedes
the constraint check, since the Regions dimension cannot be assembled
without it.

A source whose Region Azure did not report (documented as possible for
AppliedScopeType == Shared) or that is absent from the listing
contributes an unknown-region sentinel instead of being dropped:
dropping it would make "we do not know where this is" mean
"unconstrained", the fail-open shape fixed in #1495. A permission with no
Regions constraint is unaffected, matching unattributedAccountConstraint's
posture on the AccountIDs dimension.

Regression coverage asserts the security property rather than the
constraint set as built: an eastus-only caller is denied a westeurope
source, an unreported source region denies, and the sentinel does not
block an unscoped caller. The first two fail against the pre-fix call
site by reaching CalculateExchange.
cristim added a commit that referenced this pull request Jul 28, 2026
…ge (closes #596) (#1515)

* feat(azure/compute): CalculateExchange and DoExchange client operations

Adds CalculateExchange (price/preview + compatible offerings) and
ExecuteExchange (commit) as thin wrappers over the armreservations
CalculateExchange/Exchange LRO APIs, with an injected caller-func test
seam so the async polling never has to run for real in tests.

Fail-loud validation throughout: no quantity coercion, no default
reservation term, and a nil-Properties/empty-SessionID response is an
explicit error rather than a fabricated empty preview. Money fields
(NetPayable, RefundsTotal, PurchasesTotal, BillingCurrencyTotal) are
pointers so an absent amount is never read as free.

Refs #473, closes #596 (client half; API handlers follow).

* feat(api): Azure RI exchange compatible-offerings and execute endpoints

Adds POST /api/ri-exchange/azure-instances/compatible-offerings (view:purchases)
and POST /api/ri-exchange/azure-instances/exchange (execute:ri-exchange).

The execute handler never trusts a client-supplied session: it re-runs
CalculateExchange itself against the caller's sources/targets and executes
only the fresh SessionID that call returns. Execution is refused on any
policy error, a nil net payable, a currency mismatch, or a net payable that
exceeds the caller's mandatory max_payment_due cap -- each guardrail is
checked against the server's own re-quote, not anything the client sent.

Constraint enforcement (execute:ri-exchange) carries AccountIDs from the
resolved CloudAccount, Providers/Services fixed to azure/compute, every
target region, and MaxPurchaseAmount from the cap, matching the AWS
executeExchange precedent (SEC-01, issue #1141).

Widens azureExchangeClient with CalculateExchange/ExecuteExchange; extends
the existing stub to match.

Closes #596 (API half; client operations landed separately).

* test(api): Azure exchange handler coverage

Table-driven coverage for the compatible-offerings and execute handlers:
auth fail-closed (no session, missing execute:ri-exchange, constraint
denial), every validation reject, and each execute money-path guardrail
(cap exceeded, policy errors, currency mismatch, nil net payable) proven
via a mock CalculateExchange result with ExecuteExchange asserted never
called. The happy path asserts ExecuteExchange receives exactly the
SessionID the CalculateExchange mock returned, proving the server-re-quote
wiring end to end.

Also folds in the golangci-lint (v2.10.1) findings this branch introduced:
two err-shadow fixes in executeAzureExchange (match the existing
executeExchange plain-assignment style instead of `if err := ...`), two
godot comment-period fixes, and two gocritic nits in exchange_operations.go
(index-based range to avoid a large struct copy; named results on
extractPrice).

Refs #596.

* fix(api): reject currency-blind MaxPurchaseAmount cap on Azure execute

MaxPurchaseAmount permission constraints are USD-denominated (matching the
AWS execute:ri-exchange precedent), but the Azure execute handler was
feeding the raw requested amount into the constraint check regardless of
body.Currency. A non-USD request (e.g. 1000 KWD, worth far more than
1000 USD) could clear a cap meant to bound USD spend, since the comparison
was a plain float check with no currency awareness.

There is no FX conversion available here, so a non-USD amount can never be
safely compared against the cap. Fail closed instead: a non-USD request is
checked with an unmatchable sentinel amount, denying it whenever the
granting permission carries any MaxPurchaseAmount constraint, while still
allowing it through when the permission has no amount constraint at all. A
second disambiguation call (amount neutralized) distinguishes "the cap
blocked this" from "some other constraint dimension blocked this" so the
error message stays accurate.

Also adds a defensive nil check in checkAzureExchangeMoneyGuardrails so a
future azureExchangeClient implementation returning a nil preview cannot
panic the handler instead of erroring.

Both guards verified to fail their regression test when removed (checked
by hand, reverted before committing).

* fix(api): drop dead action parameter from requirePermissionConstraints

golangci-lint's full-tree run (CI's Lint Code job, no --new-from-rev) flagged
`action` as always receiving "execute" across every call site: my currency-
blind-cap fix added a second Azure call site alongside the existing AWS
execute:ri-exchange and execute:purchases call sites, all three literal
"execute". A parameter with only one real value across every caller is dead
flexibility, so the parameter is removed and the action is hardcoded as
requirePermissionConstraintsAction rather than threaded through four call
sites for a value none of them vary.

No behavior change: every call site already passed "execute".

* fix(azure/compute): refuse exchange results with a failed terminal status

CalculateExchange and ExecuteExchange only treated a non-nil
result.Error as failure. Azure's contract documents Error as "required
if status == failed or status == canceled", but a response that
violates it (Failed/Cancelled with a nil Error) was reported as a
success: the execute handler returned HTTP 200 with status "Failed"
and logged "azure ri-exchange executed", giving the caller no signal
that the exchange did not happen. On the quote side a Failed/Cancelled
response carrying a SessionID was handed straight to ExecuteExchange,
which commits whatever session the fresh quote returned.

Assert the terminal status when Azure populates it:

- CalculateExchange requires Succeeded.
- ExecuteExchange accepts Succeeded, PendingPurchases and
  PendingRefunds (the swap is committed, one leg still settling) and
  refuses everything else, including statuses this SDK version does
  not know. An unrecognized post-commit status is genuinely ambiguous,
  so the error tells the operator to verify in the portal rather than
  retry into a possible double exchange.

A nil status leaves the pre-existing guards (SessionID presence,
non-nil Properties) in charge rather than inventing a failure Azure
never reported.

Regression tests cover both refused statuses and both accepted pending
statuses; the refusal tests fail against the pre-fix code.

* fix(api): derive azure exchange billing scope from the authorized subscription

targets[].billing_scope_id was a required, caller-supplied field that went
straight through to the purchase, while authorization was checked against
the request's subscription_id. The execute:ri-exchange AccountIDs constraint
is evaluated against the CloudAccount registered for subscription_id, so a
caller could pass their own subscription_id to satisfy the constraint and
then name a different subscription's billing scope in targets[], moving the
charge outside the account whose permissions were actually verified.

Derive the billing scope from subscription_id instead, matching every other
Azure reservation purchase path in this repo (ComputeClient
.buildReservationBody and the database / cache / search / cosmosdb /
synapse / managedredis clients all build the scope from their own
subscription ID). billing_scope_id becomes optional, and is rejected when
supplied with anything other than the request subscription's own scope
rather than silently ignored, so a caller who believed they were directing
the charge elsewhere is told they were not.

Covers both the offerings and execute endpoints, with the openapi schema
updated to match.

* test(api): split azure exchange tests out and fix a misplaced doc comment

Addresses two CodeRabbit nitpicks on the Azure RI exchange tests.

The doc comment describing TestCheckAzureExchangeMoneyGuardrails_NilPreviewRefused
sat above TestCheckAzureExchangeMoneyGuardrails_CurrencyCaseInsensitive, so
both comments stacked over the wrong test and the nil-preview rationale
appeared to explain the currency rule. Move it to directly precede the test
it documents; neither test body changes.

Move the Azure compatible-offerings, execute, money-guardrail and
subscription-scoping suites into handler_ri_exchange_azure_test.go in the
same package. handler_ri_exchange_test.go had grown to 2788 lines against
the project's 500-line guideline, and the Azure block was a self-contained
~1000-line section with its own mocks and helpers. Same package, so shared
setup keeps working unchanged.

No test was added, removed or altered: 86 test functions before, 86 after.

* fix(api): check Azure exchange subscription scope before building client

authorizeAzureExchangeExecution built the Azure client (buildAzureExchangeClient)
before checking requireAzureSubscriptionScope, the reverse of the sibling
getAzureCompatibleOfferings handler. For a scoped session this let an
unregistered subscription_id short-circuit with buildAzureExchangeClient's
distinguishable 404 ("no Azure account registered for subscription %q")
before the scope check ever ran, while a registered-but-out-of-scope
subscription reached the generic errNotFound instead -- an enumeration
oracle letting a scoped caller learn which subscription IDs exist in the
tenant. Building the client first also meant credentials for an
out-of-scope account could be resolved before the request was denied.

Reorders the check to run first, matching getAzureCompatibleOfferings
exactly: scope check, then client build.

Three new tests: an unregistered subscription and a registered-but-out-of-
scope one now produce the identical generic errNotFound with no
subscription-id echoed back, and a client_secret account with no stored
secret proves no credential resolution is attempted before the scope
denial (reverting the reorder makes all three fail: one on the
distinguishable message, one on the credential-resolution side effect
never getting to the scope check's own auth call, and one on the two
error messages no longer matching).

* fix(azure/compute): never drop a message-less exchange policy error

extractExchangePreview only appended a policy violation when Azure
populated its Message field. armreservations.ExchangePolicyError has two
optional pointer fields, so Azure may report a violation as a bare Code.

The execute handler gates solely on len(ExchangePreview.PolicyErrors) > 0,
so a Code-only violation emptied the slice and let a policy-rejected
exchange be committed for real money.

Every entry now renders to a non-empty string: "Code: Message" when both
are present, otherwise whichever is set, otherwise an explicit
unspecified-violation marker. One reported violation always yields
exactly one entry.

* test(api): pin the azure exchange authz set and commit-path failures

The execute suite matched the SEC-01 constraint argument with
mock.Anything or an amount-only MatchedBy, so four of the five dimensions
that confine an irreversible exchange were asserted nowhere: dropping
Regions, or pointing AccountIDs at the wrong CloudAccount, left the whole
suite green. targetLocations was entirely unexercised.

Adds, each verified to fail against a deliberately mutated handler:

- the full constraint set (AccountIDs from the request's own
  subscription_id, azure/compute, de-duplicated target Regions, cap)
- lowercase "usd" through the handler, pinning the isUSD EqualFold that
  the direct-call guardrail test cannot reach
- the disambiguation branch where another dimension is the real denial
  cause, which must not be reported as a currency problem
- a failed re-quote aborting before ExecuteExchange is reached
- 400-vs-500 classification on the commit call itself
- cap boundary (exactly at cap, one cent over) and negative NetPayable,
  the refund side of a downgrade
- case-variant billing_scope_id accepted, status codes on the missing
  guardrail validations

* docs(api): document the azure exchange sources/targets item bounds

The handler rejects empty arrays and more than maxAzureExchangeItems (50)
sources or targets, but the spec advertised unbounded arrays, so clients
only discovered the limit from a 400. Adds minItems/maxItems to both the
compatible-offerings and execute request bodies.

* test(api): guard the offerings endpoint against the enumeration oracle

The execute endpoint has three tests pinning that
requireAzureSubscriptionScope runs before buildAzureExchangeClient. The
offerings endpoint had none: its only scope test injects an
azureExchangeFactory, so the client build always succeeds and swapping the
two calls would still yield errNotFound.

Exercises the real buildAzureExchangeClient path for both an unregistered
and a registered-but-out-of-scope subscription, and requires the two
denials to be byte-identical, so a scoped caller cannot probe which
subscription ids exist. Verified to fail against a handler with the two
calls swapped.

* docs(api): give the azure exchange sources/targets real schemas

Both request bodies declared items as a bare `type: object`, so the
contract exposed none of the fields the handler requires and generated
clients could not construct or validate a payload. In particular
billing_scope_id was described in prose but absent from the schema.

Adds reusable AzureExchangeSource / AzureExchangeTarget component schemas
referenced from both endpoints, with types, requiredness, minimums, and
the caveat that billing_scope_id is not the scope that gets charged.

The term enum is P1Y/P3Y/P5Y, matching what
azureReservationTermFromString actually accepts (the SDK's own
PossibleReservationTermValues), not the narrower set VM reservations are
sold under -- Azure decides that per resource type and reports it as a
policy error. Corrects an ExchangeTarget doc comment that claimed the SDK
only had P1Y and P3Y.

* docs(azure/compute): correct the documented reservation term set

Both Term fields claimed the ISO 8601 term is "P1Y" or "P3Y". The values
are stringified from armreservations.PossibleReservationTermValues(),
which also includes P5Y in this SDK version, so Azure can return a
compatible offering or an existing reservation carrying a term the
comment says cannot occur. A consumer trusting it would treat a valid
term as unsupported.

Also states that the one/three-year pair is not the boundary: which terms
Azure sells is per resource type and is Azure's call, surfaced as a policy
error rather than filtered here.

* fix(api): scope azure exchange sources to the authorized subscription

Closes #1527.

Every gate on these endpoints constrained the DESTINATION of an exchange:
allowed_accounts scope, the execute:ri-exchange AccountIDs constraint, and
the derived target billing scope all key off subscription_id. The sources
were validated only for a non-empty reservation_id and quantity >= 1.

Azure reservation orders are tenant-scoped, so ListExchangeableReservations
enumerates the whole tenant by design. A caller authorized for subscription
A could therefore name subscription B's reservation ids and hand B's
commitments back, buying the replacement into A's billing scope, with Azure
RBAC on the reservation order as the only backstop.

Each source must now be billed to the authorized subscription. The check
uses the reservation's own BillingScopeID, which Azure documents as the
subscription charged for it and which is the scope an exchange refunds it
to. That is the right discriminator even for AppliedScopeType Shared, which
governs which subscriptions get the discount rather than which one paid; an
AppliedScopes check would pass for nearly every reservation.

Fails closed throughout: a source missing from the listing, one Azure
reports without a billing scope, and a failed listing call are all refused
rather than permitted. Denials are byte-identical whether the reservation
belongs to someone else or does not exist, so the gate cannot be used to
enumerate reservation ids elsewhere in the tenant.

Applied to the pricing endpoint too, where the same gap leaks another
subscription's commitment value.

parseAzureExecuteRequest is extracted purely to keep executeAzureExchange
within the gocyclo limit the new gate pushed it over; it makes no decisions
of its own.

* style(api): fix lint on the source-ownership gate

golangci v2.10.1 on the root module flagged two issues in the new code:
misspell on "theatre" in a doc comment, and gocritic rangeValCopy on the
ownership map build (160 bytes copied per iteration). Index instead.

* fix(auth/api): require every requested region to be permitted

The Regions dimension of a permission constraint was matched with
containsAny, the same any-overlap rule the other dimensions use. That
rule is safe for AccountIDs, Providers and Services because a request
names exactly one value on each of them, so any-match and all-match
coincide. Regions is the one dimension a single request legitimately
spans several values on: an Azure RI exchange submits every target's
location at once via targetLocations.

The result was an authorization bypass on a money path. A caller
permitted only in eastus could attach a westus target to the exchange;
containsAny found eastus in the permitted set, returned true, and the
irreversible exchange executed for BOTH regions. "Regions limits to
specific regions" cannot mean "limits to requests that mention at least
one permitted region".

matchAllRegionsConstraint now backs that dimension: every requested
region must be permitted, comparison is case- and whitespace-
insensitive, and the empty-list semantics are unchanged (an
unconstrained permission, or a request naming no region, still
matches). Every other dimension keeps containsAny. Single-region
callers (the AWS reshape execute path, purchaseConstraintSets) are
unaffected: for a one-element request all-match and any-match are the
same test, and the case-insensitivity only widens what they match.

Alongside it, targetLocations canonicalizes each location to trimmed
lower case before de-duplicating, so the constraint set names each
target region exactly once instead of demanding a permission for two
spellings of one place ("EastUS", the casing the Azure portal shows,
and "eastus", the casing its APIs return). Target validation now
rejects a whitespace-only location as well, which previously survived
the empty check and would reach the permission check as "", a region
no permission can name.

Regression tests cover the bypass through both matchConstraints and
the real HasPermission entry point, the case and whitespace handling
on both sides, and the targetLocations normalization both directly and
through executeAzureExchange's submitted constraint set. Each fails
against the pre-fix code.

Refs #596

* fix(api/azure): bound RI exchange sources by the Regions constraint

An Azure exchange mutates two sets of reservations: the ones handed back
(sources) and the ones acquired (targets). Only the targets reached the
Regions dimension of the execute:ri-exchange constraint check, so a
caller permitted solely in eastus could POST an exchange whose source was
their own subscription's westeurope Standard_M128 3-year reservation and
whose target was a single eastus reservation. targetLocations returned
["eastus"], the region constraint passed, requireAzureSourceOwnership
passed (the westeurope reservation IS billed to that subscription, and
that gate keys on BillingScopeID rather than region), and the exchange
committed: a westeurope commitment the caller was never authorized to
touch was consumed and relocated to eastus, irreversibly.

No other gate covered it -- ExchangeableReservation.Region was consulted
by nothing. The AWS analog is safe only because AWS exchanges are
same-region; cross-region is this feature's stated purpose.

exchangeRegions now folds each source reservation's own region into the
same normalized, de-duplicated set as the target locations, so every
region the operation touches must be permitted. Source regions come from
the tenant listing rather than the request body, which names only
reservation ids; the listing is fetched once in
authorizeAzureExchangeExecution and reused by requireAzureSourceOwnership
so both source-side gates judge the same data. That listing now precedes
the constraint check, since the Regions dimension cannot be assembled
without it.

A source whose Region Azure did not report (documented as possible for
AppliedScopeType == Shared) or that is absent from the listing
contributes an unknown-region sentinel instead of being dropped:
dropping it would make "we do not know where this is" mean
"unconstrained", the fail-open shape fixed in #1495. A permission with no
Regions constraint is unaffected, matching unattributedAccountConstraint's
posture on the AccountIDs dimension.

Regression coverage asserts the security property rather than the
constraint set as built: an eastus-only caller is denied a westeurope
source, an unreported source region denies, and the sentinel does not
block an unscoped caller. The first two fail against the pre-fix call
site by reaching CalculateExchange.

* fix(api/azure): check exchange source ownership before the constraints

Folding source regions into the Regions dimension put the constraint
check ahead of requireAzureSourceOwnership, and the two together formed
an enumeration oracle. A caller scoped to subscription A and permitted
only in eastus received two distinguishable 403s for a reservation id
they do not own: an id that exists in eastus but is billed to
subscription B cleared the Regions dimension and was refused by the
ownership gate, while an id that does not exist -- or lives in an
unpermitted region -- tripped the sentinel or the foreign region and was
refused by the constraint check with a different message. The difference
confirms "this reservation id exists, in one of my permitted regions, in
a subscription I am not scoped to", which is exactly what
requireAzureSourceOwnership's deliberately identical denials withhold.

Ownership now runs immediately after the tenant listing and before the
constraint check, so the whole path is as indistinguishable as the gate
already was. The listing is in hand at that point, so there is no extra
round trip, and executeAzureExchange no longer needs the listing handed
back to it.

It also sharpens the sentinel: every source reaching exchangeRegions is
known-owned, so unknown-region means only "owned, but Azure reported no
region" rather than doubling as "not yours" or "not real". The
missing-source branch stays as a fail-closed default for any future
caller without that guarantee.

The existing TestRequireAzureSourceOwnership_DenialsAreIndistinguishable
stays green with this bug present, because it calls the gate directly
rather than driving the path. The new test drives the handler and asserts
both probes return the same status and body; it fails against the
previous ordering with the two messages diverging.
@cristim
cristim deleted the docs/mcp-server-design branch August 25, 2026 23:48
cristim added a commit that referenced this pull request Sep 27, 2026
…, GCP (#1495)

* 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(mcp): remove architecture blueprint doc

Design work is complete; implementation follows in this same PR.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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).

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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).

* 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.

* 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.

* 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.

* 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.

* 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 "<N>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.

* 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.

* 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).

* 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.

* 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).

* 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.

* fix(mcp): scope Savings Plans Details.Region/InstanceFamily to EC2Instance

common.SavingsPlanDetails documents Region and InstanceFamily as only
populated for EC2Instance plans, but savingsPlanRecommendationFromArgs set
both unconditionally from caller args. A Compute/SageMaker/Database (account
-level, family-agnostic) request with a caller-supplied region or
instance_family leaked that value into Details, violating the documented
invariant that other Details consumers rely on.

Populate InstanceFamily and Region only when sp_type=EC2Instance, matching
the validation that already requires both fields for that type.

* fix(azure): validate payment option before Microsoft.Capacity registration

PurchaseCommitment called ensureCapacityProviderRegistered (a real ARM
resource-provider GET, and a POST to register when unregistered) before
buildReservationBody validated the payment option via
BillingPlanForPaymentOption. An invalid or empty PaymentOption still
triggered that side-effecting call before being rejected.

Resolve the billing plan via BillingPlanForPaymentOption first and thread
the parsed value into buildReservationBody, so an invalid payment option
fails loud before any side effect and the plan is no longer parsed twice.

* fix(mcp): trim surrounding whitespace on required identifier fields

requireNonBlank rejected an all-whitespace value but let a value with
surrounding whitespace (e.g. " us-east-1 ") pass through unchanged into
rec.Region/rec.ResourceType/Details and, for region, into resolveClient's
ProviderConfig.Region and GetServiceClient call.

requireNonBlank now returns the trimmed form on success. Every purchase
tool (EC2, RDS, ElastiCache, the OpenSearch/Redshift/MemoryDB group, Azure
VM, GCP Compute Engine) stores and threads the trimmed value through
rec.Region/rec.ResourceType/Details and, for region, returns it from the
recommendation-building function so resolveClient never resolves the
provider/service client against a raw, un-trimmed value.

* fix(mcp): validate lookback_period in the search_recommendations handler

lookback_period was constrained only by the tool's advertised jsonschema
enum, not re-validated in the handler, so a direct MCP call bypassing schema
enforcement could pass an unsupported value through to the provider.

Add the typed LookbackPeriod enum and validate against it in
validateSearchArgs, consistent with how payment_option and the other
enumerable fields are already re-validated at the handler boundary.

* fix(azure): validate term before capacity provider registration

buildReservationBody parses rec.Term via ParseTermYears after
ensureCapacityProviderRegistered already ran, so an invalid term
still triggered the Microsoft.Capacity provider-registration side
effect before the purchase was rejected. Move body construction
(which validates both payment option and term) ahead of
registration in PurchaseCommitment.

* fix(mcp): trim region/instance_family before storing on SP recommendation

validateSavingsPlanArgs only trimmed args.Region and args.InstanceFamily
for the blank-check; savingsPlanRecommendationFromArgs then stored the
raw, untrimmed values into the resolved region, rec.Region, and
Details.Region/InstanceFamily. Those flow into ProviderConfig,
GetServiceClient, and the DescribeSavingsPlansOfferings lookup for a
real EC2Instance Savings Plan purchase, so " us-east-1 " passed
validation but could reach a real purchase with surrounding whitespace
intact.

* fix(mcp): trim region/account identifiers in search_recommendations

region, include_regions, exclude_regions, and account_filter were
passed straight through to ProviderConfig.Region and
RecommendationParams with no trim, unlike the purchase tools'
requireNonBlank. A caller-supplied " us-east-1 " could resolve the
wrong (or account-default) region and silently miss matching
recommendations. region is optional here, so trim rather than reject
surrounding whitespace.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* fix(mcp): default Savings Plans search to 1yr no-upfront 30d

AWS's GetSavingsPlansPurchaseRecommendation requires term, payment
option, and lookback period on every call, unlike
GetReservationPurchaseRecommendation (EC2/RDS/etc), which defaults
them server-side when omitted. cudly_search_recommendations advertised
these three fields as optional for every service, so an AWS Savings
Plans search omitting them failed against Cost Explorer one field at a
time.

When the search targets an AWS Savings Plans service and the caller
left a field blank, default payment_option to no-upfront, term_years
to 1, and lookback_period to 30d before validating and building the
provider call. A caller-supplied value is never overridden, and
EC2/RDS/etc searches are unaffected. Update the jsonschema
descriptions to state the per-service default/omit behavior instead of
a blanket "omit to search all".

* fix(mcp): join all invalid search fields into one error

validateSearchArgs returned on the first invalid field it found, so a
caller with several bad fields had to fix and resubmit repeatedly to
discover the next one. Collect every payment_option/lookback_period/
term_years/sp_type validation failure and errors.Join them into a
single returned error instead.

Also add requireSavingsPlansSearchFields as a safety net: after the
Savings Plans defaults from the previous commit run, an AWS SP search
should never still be missing payment_option/term_years/
lookback_period. If it somehow is, name every missing field in one
error rather than letting the request reach Cost Explorer and fail one
field at a time.

* docs(mcp): document Savings Plans search defaults

Note in the known-gaps section that cudly_search_recommendations
defaults term_years/payment_option/lookback_period to 1yr/no-upfront/
30d for AWS Savings Plans searches, and why reservation searches
(EC2/RDS/etc) are unaffected.

* fix(aws): filter recommendations by the region search param

GetReservationPurchaseRecommendation and
GetSavingsPlansPurchaseRecommendation are both account-level Cost
Explorer calls with no region parameter -- AWS returns recommendations
from every region the account has usage in regardless of what the
caller asked for. applyRecommendationFilters already honored
include_regions/exclude_regions, but a caller passing only region (as
cudly_search_recommendations documents doing) got no region filtering
at all, so a us-east-1 search could surface an eu-west-1
recommendation.

Fold Region into the include-region set before filtering, matching the
existing "in addition to (or instead of) region" semantics
include_regions already advertises. No behavior change when no region
constraint is supplied.

* fix(aws): stop region filter from dropping all savings plans recs

Savings Plans recommendations never populate the top-level
Recommendation.Region: account-level plans (Compute/SageMaker/Database)
carry no region at all, and EC2Instance plans carry their region in
Details.Region instead. filterByIncludedRegions/filterByExcludedRegions
matched only the top-level field, so any region or include_regions
search silently dropped every Savings Plans recommendation, even when
matching ones existed.

Add effectiveRegion() to fall back to Details.Region for EC2Instance
plans and treat a still-empty region as region-agnostic (always kept
by an include filter, never dropped by an exclude filter) rather than
"belongs to no region". Also normalize Details.Region at the parser
(extractEC2SPFields) so it compares against the same canonical region
codes the include/exclude sets use, matching the reservation parsers.

* test(aws): cover savings plans recs in region filter tests

TestApplyRecommendationFilters_Region only used fixtures with a
populated top-level Region, so it could not catch a region filter
silently dropping Savings Plans recommendations (which never populate
that field). Add TestApplyRecommendationFilters_SavingsPlanRegion with
an account-level SP rec (Region and Details.Region both empty) and an
EC2Instance-style SP rec (Region empty, Details.Region set), asserting
both include and exclude region filters behave correctly for each.

* fix(mcp): scope purchase idempotency key to the target account

The MCP idempotency key was derived only from product dimensions (provider,
region, service, resource type, count, term, payment option, Details). Those
describe WHAT is bought, never WHERE it lands, so two purchases identical in
every product dimension but billed to different accounts derived the same
token.

On Azure that silently skips a real purchase rather than merely being
imprecise. reservations.FindReservationOrderByIdempotencyToken lists
reservation orders from the tenant-wide endpoint (ReservationOrdersListURL
carries no subscription prefix, and its comment relies on the token being
"globally unique per (execution, rec)" -- true for the CLI/web paths whose
tokens come from a UUID execution ID, not for the MCP path whose token is
purely a function of the request). Buying the same VM reservation for a
second subscription in the same tenant therefore matched the first
subscription's order by token, short-circuited, and reported success without
buying anything for the second subscription.

Add PurchaseRequest.CredentialScope, folded into idempotencyKeyFor, and
populate it in all nine purchase tools via the new CredentialScope() helper:
the caller-supplied override (aws_profile / azure_subscription_id /
gcp_project_id) when present, otherwise the ambient environment variable the
matching provider factory itself consults. AWS (per-account tag/ClientToken
lookups) and GCP (per-project commitment names) scope their own dedupe, so
this is defense in depth there and load-bearing on Azure.

Regression tests cover the key-level distinction, the end-to-end threading
into the token the provider actually dedupes on, and CredentialScope's
override/environment/whitespace precedence.

* fix(aws): stop region-less reservation recs bypassing the region filter

The Savings Plans region fix earlier in this branch exempted a
recommendation from both region filters whenever its effective region was
empty, so that account-level Compute/SageMaker/Database plans (which carry
no region at all) survive a region constraint. That predicate is too broad:
an empty region is not by itself proof that a rec is region-agnostic.

Every reservation parser in parser_services.go assigns rec.Region only under
`if <svc>Details.Region != nil`, so an EC2/RDS/ElastiCache/OpenSearch/
Redshift/MemoryDB recommendation whose Cost Explorer payload omitted the
region field reaches the filters with Region == "" while still being a
single-region purchase. Exempting those let a recommendation of unknown
region survive an explicit "us-east-1 only" filter and reach the purchase
path, to be bought in whatever region the service client resolved. This
filter is shared by the CLI, the web API, and the scheduler, so the blast
radius was not limited to the new MCP search tool.

Gate the exemption on CommitmentType == CommitmentSavingsPlan, which is what
parser_sp.go sets and what "region-agnostic" actually means. A reservation
rec with no region is once again dropped by an include filter (its region
cannot be shown to match) and kept by an exclude filter (it cannot be shown
to be excluded), the conservative direction each filter had before Savings
Plans support was added.

Also skip blank entries when building either filter's lookup set: a
caller-supplied "" matches no real region code, and in the exclude filter it
would otherwise drop every region-agnostic rec via a key that was never a
region.

Regression tests cover both, and the region-less-reservation cases fail
against the empty-region-means-agnostic predicate.

* fix(mcp): reject Savings Plans commitments finer than one cent

providers/aws/services/savingsplans/client.go renders
CreateSavingsPlanInput.Commitment with %.2f, so any hourly_commitment finer
than a cent was silently rounded on the way to AWS: a requested $0.004/hour
committed to "0.00" and a requested $10.005/hour committed to "10.01". The
tool only checked > 0, so both were accepted and spent real money on a
figure the caller never asked for -- exactly the silent coercion this money
path must not do.

Validate at the tool boundary that the caller's amount survives that same
%.2f rendering unchanged, and fail loud naming the amount AWS would actually
have been asked to commit. The comparison is against the rendered-then-
reparsed value with a relative epsilon rather than an exact whole-cents
test, because decimal literals like 0.07 are not exactly representable in
float64 (0.07*100 is 7.000000000000001) and an exact test would reject most
real commitments.

This also keeps the idempotency key honest: idempotencyKeyFor folds in the
full-precision HourlyCommitment, so two requests AWS would bill identically
(10.001 and 10.004, both "10.00") previously derived different tokens and
could purchase twice.

* fix(mcp): reject blank filter entries and trim service in search

Two input-hygiene gaps in cudly_search_recommendations, both of which
returned a wrong answer instead of an error.

A blank entry in include_regions/exclude_regions/account_filter cannot match
any real region code or account ID, but a non-empty list still switches the
corresponding filter on. include_regions=["  "] therefore activated region
filtering with a set that matches nothing and returned zero
recommendations, which a caller reads as "your account has nothing worth
buying" rather than "your filter was malformed". Reject blank entries by
name and index, joined with the other validation errors so a caller sees
every problem at once.

service was the one identifier field never trimmed, while region,
include_regions, exclude_regions and account_filter all were. A padded
" savingsplans-compute" both missed the AWS Savings Plans defaulting branch
(isAWSSavingsPlansSearch matches on the raw value) and failed the
supported-service check for a reason the error text did not explain. Trim it
with the others, and move the whole trimming step ahead of validation so
every check sees the normalized values the rest of the call actually uses.

Extract the per-field validation into collectSearchFieldErrors to keep
validateSearchArgs under the pre-commit gocyclo gate of 10.

* fix(mcp): set the GCP CUD payment option instead of leaving it empty

cudly_gcp_computeengine_cud_purchase built its Recommendation without a
PaymentOption. GCP Compute Engine CUDs have exactly one billing schedule
(monthly over the term, no upfront option), so the tool correctly exposes no
payment_option argument, but an empty string is not neutral downstream: the
offering-details switch in providers/gcp/services/computeengine/client.go
has no case for "" and falls through to `default: upfrontCost = totalCost`,
reporting the entire commitment as an upfront charge.

Set it explicitly to "monthly", the sole value
config.ValidPaymentOptionsByProvider["gcp"] recognises, and say why in the
tool description so a caller does not go looking for the missing argument.

* feat(mcp): log real purchases and document the missing approval gate

A real MCP purchase left no record anywhere. The CLI path emits a
common.AuditRecord per purchase (cmd/multi_service.go) and the web path
persists a purchase_executions row that also carries the approval history,
but the mcp/ tree contained no logging at all, so an operator asking "what
did the assistant actually buy?" had nothing to read.

Log an ATTEMPT line before the provider call and a matching OK/FAILED line
after, recording provider, target account, region, resource, count, term,
payment option, commitment ID and a masked idempotency token. Previews are
deliberately not logged: they contact no provider and spend nothing, and
logging every dry run would bury the real purchases. Output goes to the
standard logger (stderr), never stdout, which the MCP stdio transport owns
for JSON-RPC framing.

Also document in mcp/README.md what this server does not provide, so the
gaps are a deliberate choice rather than a surprise: no approval workflow
(confirm=true is a guardrail against an accidental call, not an
authorization control, and the model supplies it), no persisted audit
record in CUDly's own purchase history, and credentials bounded only by
what launched the process since the per-call account arguments are chosen
by the model.

* fix(azure): diagnose a missing payment option as missing

BillingPlanForPaymentOption folded empty into the same error as an
unrecognized value, so a purchase that failed because no payment option was
supplied was told "partial-upfront has no azure equivalent". That is true
and irrelevant, and it sends whoever reads the failure looking in the wrong
place.

Empty is a reachable state with its own remedy, not a typo: migration 000032
added recommendations.payment_option as TEXT NOT NULL defaulting to the
empty string, so a row predating that migration reaches
internal/purchase/execution.go with rec.Payment == "" and lands here. Give
it a message that names the actual problem and says why a default is not
applied for it (Azure's own default is upfront, which would charge the whole
commitment immediately).

Also correct ReservationOrdersListURL's doc comment. It asserted the
idempotency token is "globally unique per (execution, rec)", stated as a
property of the token itself; it is really a property the CALLER must
provide, satisfied by the CLI/web paths only because they derive the token
from a UUID execution ID. Spell out that a caller deriving a token from
request parameters must fold in the target subscription, since this lookup
is tenant-wide and would otherwise let two subscriptions in one tenant
collide.

* style(mcp): resolve golangci-lint v2.10.1 findings

Three findings from the CI-pinned linter (v2.10.1, matching ci.yml, not the
newer local default): a govet shadow on the named err return in
validateSavingsPlanArgs, a misspell in the GCP payment-option comment, and a
staticcheck S1021 split declaration in the new audit-logging test.

The shadow fix uses a distinct variable name rather than `err =`, because
plain reassignment trips gocritic's sloppyReassign in the same spot: with a
named err return, only a differently-named local satisfies both linters.

* test(aws): pin blank region skip and savings plans region normalization

Addresses two CodeRabbit nitpicks.

TestApplyRecommendationFilters_BlankRegionEntryIsNotAMatcher did not pin
what it claimed. It used an account-level Savings Plan, which is
region-agnostic, so filterByExcludedRegions short-circuits on
isRegionAgnostic before the lookup map is ever consulted; the subtest passed
with or without regionSet's blank skip. Switch the subject to a region-less
reservation, which is NOT region-agnostic but still has an empty effective
region, so it reaches the map lookup and is dropped by a "" key when the
skip is absent. Verified the strengthened test fails with the skip removed.

Add a regression test for extractEC2SPFields' region normalization. Cost
Explorer sometimes returns a display name ("US East (N. Virginia)") rather
than a region code, and downstream region filters compare against codes like
"us-east-1", so an un-normalized value silently drops an EC2Instance Savings
Plans recommendation from a search that explicitly asked for its region.

* refactor(mcp): share one dry_run/confirm default resolver across tools

Addresses a CodeRabbit nitpick. All seven purchase tools re-implemented the
same block: default dry_run to true, default confirm to false, override each
from its *bool when non-nil. EC2 had it as effectiveDryRunConfirm, typed to
ec2RIPurchaseArgs so nothing else could reuse it; the other six inlined it.

This is the gate that decides whether real money moves, so seven hand-copied
versions were seven chances for one to drift. A copy that defaulted dryRun
to false would silently turn an unconfirmed preview into a live purchase,
and only review would catch it.

Replace all seven with ResolveDryRunConfirm(dryRun, confirm *bool), taking
the pointers directly so it is not tied to any one tool's args struct.
Behavior is unchanged. The table-driven test covers the safety-critical row
explicitly: confirm=true with dry_run omitted still resolves to a preview.

* feat(mcp): offer Archera insurance after a purchase completes

A completed MCP purchase now returns an `archera` block: the
underutilization-insurance pitch, the signup link carrying CUDly
attribution, the enrollment window in days, and both partnership
disclosures. This mirrors what the CLI already prints after a real purchase
(printArcheraPitch) and what the web UI shows in its post-approval modal, so
a buyer going through MCP is offered the same coverage as one going through
either other surface.

Attached only when the purchase actually succeeded: never to a dry run and
never to a failed purchase, since neither bought a commitment and neither
started an enrollment window. Offering a 7-day window against a purchase
that did not happen would be wrong on the facts.

Both disclosures ship as first-class response fields rather than prose,
because an MCP client renders this payload through a model: sending the
signup link without the sponsorship and the works-fine-without-it facts
alongside it would let a sponsored recommendation be presented as a neutral
one. Tests assert the link never serializes without them.

The link, window, disclosures and pitch move to pkg/common so the CLI and
the MCP server read one definition instead of each carrying its own copy;
cmd/multi_service_stats.go now sources its wording from there. The frontend
keeps its TypeScript copy (it cannot import Go) and both sides point at each
other.

Note for follow-up, deliberately not changed here: internal/email/templates.go
sends a different signup link (archera.ai/signup?mode=cudly) than the CLI and
frontend (www.archera.ai/cudly). Reconciling them is a partnership/attribution
question rather than a refactor, so the divergence is documented next to the
constant instead of silently normalized.

* fix(mcp): reject NaN and Inf hourly commitments in Savings Plans validation

validateHourlyCommitment's `<= 0` check and %.2f-render-then-reparse trick
both let NaN and +Inf slip through uncaught, contradicting the function's
own comment that they were rejected. Add an explicit finite check so a
non-finite hourly_commitment is refused with a clear error instead of
reaching the AWS purchase call.

* fix(mcp): log a provider-reported purchase failure as FAILED, not OK

logPurchaseOutcome only checked the Go error, so PurchaseResult{Success:
false, Error: nil} -- a provider that ran the call but reports it did not
actually buy anything -- fell through to the "mcp purchase OK" audit line.
Gate the OK line on result.Success explicitly so a failed purchase is never
misrecorded as successful in the only audit trail this server keeps.

* fix(mcp): add operator opt-in gate before executing real purchases

The confirm flag on every purchase tool is model-supplied, not an
authorization control: a prompt-injected or hallucinating model with
ambient production credentials could execute a real purchase from a single
tool call, with nothing on the operator's side to stop it.

Add CUDLY_MCP_ENABLE_REAL_PURCHASES, checked at the single chokepoint in
ExecutePurchase where a real (non-preview) purchase is about to resolve a
provider client. Default off: only an exact "1" or "true" (case-insensitive)
enables real purchases; anything else, including unset, refuses with an
error naming the flag. Dry runs are unaffected either way. Document the gate
in mcp/README.md.

* fix(aws): route parser warnings to stderr, never stdout

providers/aws/recommendations is linked into cmd/cudly-mcp, and the MCP
stdio transport owns stdout for JSON-RPC framing. Two parser warnings
used fmt.Printf, so they went to stdout:

  parser_ri.go  parseRecommendations, per unparseable recommendation detail
  parser_sp.go  the multi-plan-type path, per plan type whose CE call fails

Either one fires during a cudly_search_recommendations call and injects a
bare line of prose into the middle of the JSON-RPC stream, corrupting the
protocol and breaking the client session. The SP path is not an exotic
case: it triggers whenever one plan type fails while others succeed, e.g.
Database SP being unavailable in an account.

Both now use log.Printf, which writes to stderr like every other warning
in these files.

TestParseRecommendations_SkipsInvalidDetails already drove the RI path but
only asserted the returned count, so it stayed green the whole time the
bug was live. The new TestParseRecommendations_WarningsNeverGoToStdout
asserts the property that matters by swapping os.Stdout for a pipe:
verified failing before this change and passing after.

* fix(aws): keep region-scoped EC2Instance SPs under region filters

isRegionAgnostic exempted any CommitmentSavingsPlan with an empty
effective region from both region filters. Only the account-level plan
types (Compute, SageMaker, Database) belong to no region. An EC2Instance
Savings Plan is region-scoped, and extractEC2SPFields yields Region == ""
whenever Cost Explorer omitted SavingsPlansDetails or its Region field,
since aws.ToString maps a nil pointer to "".

So a region-scoped EC2Instance SP of unknown region survived an explicit
"us-east-1 only" filter and could be purchased in whatever region the
service client resolved. That is the same defect the CommitmentSavingsPlan
gate just closed for reservations, one plan type over, and the function's
own doc comment already described the intended rule as
Compute/SageMaker/Database.

The exemption now requires positive evidence that the plan is
account-level: Details must be *SavingsPlanDetails and its PlanType must
be recognised. Anything unknown (nil Details, a non-SavingsPlanDetails
payload, an unrecognised plan type) stays region-scoped and is filtered
conservatively, matching the direction the reservation case already took.
Plan types are compared against the SDK's own sptypes.SavingsPlanType
members rather than bare literals, so this vocabulary cannot drift from
spPlanTypeDisplayString's.

TestApplyRecommendationFilters_RegionlessEC2InstanceSPNotExempt covers the
region-less EC2Instance SP under both filter shapes, the unrecognised and
missing-Details cases, that account-level plans stay exempt, and that an
EC2Instance SP carrying a real region is still matched on it. Verified
failing on the CommitmentSavingsPlan-only gate and passing after.

* fix(mcp): refuse a real purchase with no determinable account

idempotencyKeyFor folds CredentialScope into the token, and CredentialScope
returns "" when neither the explicit argument nor the ambient environment
variable is set. So the SAME target account reached two ways derived two
DIFFERENT tokens: omitting azure_subscription_id (ambient resolves to sub-X)
gave "", while passing azure_subscription_id="sub-X" gave "sub-X".

Every provider's dedupe is token-keyed, so the second call's lookup missed
and bought a second commitment:

  Azure  FindReservationOrderByIdempotencyToken
  GCP    idempotentCommitmentName (token -> commitment name)
  AWS    EC2/Redshift findRIByIdempotencyToken tag lookups,
         RDS/ElastiCache/OpenSearch/MemoryDB idempotencyGuard,
         Savings Plans CreateSavingsPlanInput.ClientToken

Measured before the fix, one account, two call shapes:

  explicit sub-X -> token=ab1016c03e7b5928 purchases=1
  ambient  ""    -> token=399b45fab938dd86 purchases=1
  same token? false    total purchases for one account: 2

Previewing without the account and then re-calling with it explicit is
ordinary self-correcting model behavior, so this was a likely sequence
rather than a corner case. The earlier review reasoned only about the
opposite direction (one token spanning two accounts) and concluded an empty
scope was safe because a purely-ambient provider is unambiguous for the
process lifetime; that is true and irrelevant, since the hazard is two
tokens for one account.

A real purchase now requires a determinable account and is refused before
any credential is resolved, naming the argument to pass. Previews are
untouched, so pricing a purchase still needs no account. Explicit and
ambient forms of a KNOWN account already converge, because CredentialScope
falls back to the same environment variable the provider factory itself
reads, so normal dedupe is preserved; only the both-absent case aliased and
it is now unreachable.

Deriving the effective account from the resolved client would be the
stronger fix, but provider.ServiceClient exposes no account accessor, so
that needs an interface change across every AWS/Azure/GCP service client.
Failing closed removes the hazard by construction meanwhile: on an
LLM-driven money path, making the operator name the target account before
spending is the same posture as EnvEnableRealPurchases.

The gates move into authorizeRealPurchase so ExecutePurchase stays under the
gocyclo:10 pre-commit threshold (it hit 11) and so "what must be true before
this server spends money" reads in one place.

TestExecutePurchaseAmbientScopeCannotDoubleBuy reproduces the
explicit-then-omitted sequence and asserts the second call never reaches the
provider; verified failing before this change. Its companion
TestExecutePurchaseExplicitAndAmbientAccountDedupe pins that the two ways of
naming a known account still derive one token, so the fix removes the
aliasing rather than relocating it.

mcp/README.md documents the new requirement, and separately the
already-landed Azure behavior change where an empty payment_option now fails
loud instead of silently billing all-upfront (reachable from scheduled rows
predating migration 000032).

* fix(mcp): fan out reservation searches over every term and payment option

cudly_search_recommendations documented "omitted means search all" for
reservation searches, and mcp/README.md stated AWS defaults term/payment
server-side when they are omitted. Neither was true.

GetReservationPurchaseRecommendation takes exactly one TermInYears and one
PaymentOption per request and returns results only for that cell. Omitting
them sent Cost Explorer EMPTY enum values (convertTermInYears and
convertPaymentOption both silently map unrecognized input to ""), AWS quietly
applied its own 1yr/all-upfront default, and the caller received one of six
purchasable offers while the tool claimed it had searched them all.

Verified against a live account: the omitted-field search returned only
1yr/all-upfront at 40% savings, hiding 3yr/all-upfront at 63% on the identical
t4g.nano. A model told "omitted searches all terms" would report the worse
offer as the only option.

Expand each omitted dimension to its full menu and issue one call per
combination (6 when both are omitted, 2 or 3 when one is). Because each call
now carries a concrete term and payment option, the AWS parser tags every
returned recommendation with the offer that produced it, so the money figures
are no longer blank and unattributable. A failing combination fails the whole
search rather than being skipped: returning five of six offers is
indistinguishable from "these are all your options" and would recreate the
same defect. Results are always a non-nil slice, so a no-results search
serializes as [] instead of sometimes [] and sometimes null.

lookback_period is deliberately NOT fanned out; it is the usage evidence
behind an offer rather than another offer to choose from, so AWS's server-side
default stands. The schema text and README now say so instead of claiming it
searches all lookback windows.

TestSearchRecommendationsEC2NoDefaultsInjected asserted the incorrect premise
in its own doc comment and is rewritten to guard what it was actually for:
the Savings Plans defaults must not narrow a reservation search, and
lookback_period must still reach AWS blank.

* fix(mcp): advertise term/payment enums and split the search test file

Addresses two CodeRabbit findings on the search fan-out commit (d76aa7c00).

The tool schema constrained only provider and lookback_period, so an MCP
client could not discover the valid payment_option/term_years values from the
schema and had to learn them by sending a value the tool rejects. Add the enum
overrides, mirroring ValidatePaymentOption / ValidateTermYears. Those
validators remain the enforcing guard: a client may send anything regardless
of what the schema declares, so this is discoverability, not a new gate.

TestSearchRecommendationsSchemaAdvertisesTermAndPaymentEnums pins both value
sets through the real MCP protocol (ListTools), following the existing Azure
partial-upfront precedent in this file, so the schema cannot drift from the
validators unnoticed. Confirmed to fail when either override is removed.

search_recommendations_test.go had grown to 651 lines, past the 500-line
guideline in CLAUDE.md. Split the AWS reservation fan-out tests into
search_recommendations_fanout_test.go along the existing topical seam,
leaving 473 and 190 lines. The shared fakes stay in the original file and
remain visible to both: same package, different file.

* fix(mcp): omit effective_date when the provider reported no timestamp

common.PurchaseResult.Timestamp is a plain time.Time that not every
provider client populates. PurchaseResponse.EffectiveDate was a plain
string set from result.Timestamp.Format(time.RFC3339), and formatting
the zero time.Time yields the literal "0001-01-01T00:00:00Z" rather
than "", so the `omitempty` tag could never drop it.

Every such response therefore advertised a real-looking commitment
start date in the year 1: a fabricated value presented as real, on a
field a caller may key billing or renewal reminders off.

Make EffectiveDate a *string and populate it through rfc3339OrNil,
which returns nil for the zero time. This is the time analog of the
existing nonZeroCostPtr, applied for the same reason the cost fields
already use pointers: keep "not known" distinguishable from a real
value instead of fabricating one.

Adds two regression tests. The guard asserts against the marshaled
JSON, not just the Go field, because the JSON payload is what actually
crosses the MCP boundary to the caller; it fails on the pre-fix code
and passes after. The companion test pins that suppressing the zero
value does not suppress a genuine timestamp.

* test(mcp): guard content index and type assertion in the binary smoke test

result.Content[0].(*gosdk.TextContent) panics on an empty Content slice
or a non-text first block. A panic aborts the whole package's test run
rather than failing this one assertion, so a regression in the tool's
error shape would surface as an unrelated-looking crash instead of a
readable failure naming the actual problem.

Check the slice is non-empty and use the comma-ok form, reporting the
concrete type when the assertion fails.

* docs(mcp): explain why the GCP CUD tool has no ambient scope fallback

Review flagged that gcp_computeengine_cud.go passes no environment
fallback to CredentialScope, unlike the AWS ("AWS_PROFILE") and Azure
("AZURE_SUBSCRIPTION_ID") tools, so omitting gcp_project_id makes
requireCredentialScope refuse every real GCP CUD purchase.

The asymmetry is deliberate and must stay. CredentialScope's contract
is that its fallback names the same variable the provider factory
itself consults, so that naming an account explicitly and letting it
resolve ambiently derive the same idempotency token. GCP has no such
variable: resolveGCPProjectID reads only config.GCPProjectID and the
deprecated config.Profile, and when both are empty NewProvider falls
through to getDefaultProject, which picks the first ACTIVE project
returned by a paginated ListProjects.

Adding GOOGLE_CLOUD_PROJECT to the CredentialScope call alone would
make the token lie: the scope would read that variable while the
purchase still landed in whatever project getDefaultProject resolved,
so the same target reached two ways would derive two different tokens
and double-buy. That is precisely the hazard requireCredentialScope
was added to close. Teaching the factory to read it too would fix the
divergence but change project selection for every other consumer of
providers/gcp (CLI, web, scheduler), and GOOGLE_CLOUD_PROJECT
conventionally names the project a process runs in rather than the one
it should buy for, so on a hosted runtime it would silently redirect
purchases.

Document the reasoning at the call site so it is not "fixed" later,
and correct the gcp_project_id schema description, which still told
the model the field was optional. It is optional for a dry_run preview
and required for a real purchase, which is what made the refusal
surprising in…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/l Weeks impact/internal Team-internal only priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(mcp): expose CUDly RI/SP purchase CLI as an MCP server for Claude

1 participant